Skip to content

Commit 42aabb1

Browse files
feat!: EIP4361 (aka Sign in with Ethereum) support
1 parent ee7f652 commit 42aabb1

File tree

3 files changed

+191
-48
lines changed

3 files changed

+191
-48
lines changed

Sources/Core/Utility/NSRegularExpression+Extension.swift

Lines changed: 20 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,75 +5,47 @@
55

66
import Foundation
77

8-
extension NSRegularExpression {
8+
public extension NSRegularExpression {
99
typealias GroupNamesSearchResult = (NSTextCheckingResult, NSTextCheckingResult, Int)
1010

11-
private func textCheckingResultsOfNamedCaptureGroups() -> [String: GroupNamesSearchResult] {
11+
private func getNamedCaptureGroups() -> [String: GroupNamesSearchResult] {
1212
var groupnames = [String: GroupNamesSearchResult]()
1313

14-
guard let greg = try? NSRegularExpression(pattern: "^\\(\\?<([\\w\\a_-]*)>$", options: NSRegularExpression.Options.dotMatchesLineSeparators) else {
14+
guard let greg = try? NSRegularExpression(pattern: "\\(\\?<([\\w\\a_-]*)>$",
15+
options: .dotMatchesLineSeparators),
16+
let reg = try? NSRegularExpression(pattern: "\\(.*?>",
17+
options: .dotMatchesLineSeparators) else {
1518
// This never happens but the alternative is to make this method throwing
1619
return groupnames
1720
}
18-
guard let reg = try? NSRegularExpression(pattern: "\\(.*?>", options: NSRegularExpression.Options.dotMatchesLineSeparators) else {
19-
// This never happens but the alternative is to make this method throwing
20-
return groupnames
21-
}
22-
let m = reg.matches(in: self.pattern, options: NSRegularExpression.MatchingOptions.withTransparentBounds, range: NSRange(location: 0, length: self.pattern.utf16.count))
23-
for (n, g) in m.enumerated() {
24-
let r = self.pattern.range(from: g.range(at: 0))
25-
let gstring = String(self.pattern[r!])
26-
let gmatch = greg.matches(in: gstring, options: NSRegularExpression.MatchingOptions.anchored, range: NSRange(location: 0, length: gstring.utf16.count))
21+
22+
let m = reg.matches(in: pattern, options: .withTransparentBounds, range: pattern.fullNSRange)
23+
for (nameIndex, g) in m.enumerated() {
24+
let r = pattern.range(from: g.range(at: 0))
25+
let gstring = String(pattern[r!])
26+
let gmatch = greg.matches(in: gstring, options: [], range: gstring.fullNSRange)
2727
if gmatch.count > 0 {
2828
let r2 = gstring.range(from: gmatch[0].range(at: 1))!
29-
groupnames[String(gstring[r2])] = (g, gmatch[0], n)
29+
groupnames[String(gstring[r2])] = (g, gmatch[0], nameIndex)
3030
}
3131

3232
}
3333
return groupnames
3434
}
3535

36-
func indexOfNamedCaptureGroups() throws -> [String: Int] {
37-
var groupnames = [String: Int]()
38-
for (name, (_, _, n)) in self.textCheckingResultsOfNamedCaptureGroups() {
39-
groupnames[name] = n + 1
40-
}
41-
return groupnames
42-
}
43-
44-
func rangesOfNamedCaptureGroups(match: NSTextCheckingResult) throws -> [String: Range<Int>] {
45-
var ranges = [String: Range<Int>]()
46-
for (name, (_, _, n)) in self.textCheckingResultsOfNamedCaptureGroups() {
47-
ranges[name] = Range(match.range(at: n+1))
48-
}
49-
return ranges
50-
}
51-
52-
private func nameForIndex(_ index: Int, from: [String: GroupNamesSearchResult]) -> String? {
53-
for (name, (_, _, n)) in from {
54-
if (n + 1) == index {
55-
return name
56-
}
57-
}
58-
return nil
59-
}
60-
6136
func captureGroups(string: String, options: NSRegularExpression.MatchingOptions = []) -> [String: String] {
62-
return captureGroups(string: string, options: options, range: NSRange(location: 0, length: string.utf16.count))
37+
captureGroups(string: string, options: options, range: string.fullNSRange)
6338
}
6439

6540
func captureGroups(string: String, options: NSRegularExpression.MatchingOptions = [], range: NSRange) -> [String: String] {
6641
var dict = [String: String]()
6742
let matchResult = matches(in: string, options: options, range: range)
68-
let names = self.textCheckingResultsOfNamedCaptureGroups()
69-
for (_, m) in matchResult.enumerated() {
70-
for i in (0..<m.numberOfRanges) {
71-
guard let r2 = string.range(from: m.range(at: i)) else {continue}
72-
let g = String(string[r2])
73-
if let name = nameForIndex(i, from: names) {
74-
dict[name] = g
75-
}
76-
}
43+
guard let match = matchResult.first else {
44+
return dict
45+
}
46+
for name in getNamedCaptureGroups().keys {
47+
guard let stringRange = string.range(from: match.range(withName: name)) else {continue}
48+
dict[name] = String(string[stringRange])
7749
}
7850
return dict
7951
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
//
2+
// EIP4361.swift
3+
//
4+
// Created by JeneaVranceanu at 19.09.2022.
5+
//
6+
7+
import Foundation
8+
import BigInt
9+
import Core
10+
11+
public typealias SIWE = EIP4361
12+
13+
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 = "(([^:?#]+):)?(([^?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?"
15+
16+
/// Sign-In with Ethereum protocol and parser implementation.
17+
/// [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361)
18+
public final class EIP4361 {
19+
20+
private static let domain = "(?<domain>([^?#]*)) wants you to sign in with your Ethereum account:"
21+
private static let address = "\\n(?<address>0x[a-zA-Z0-9]{40})\\n\\n"
22+
private static let statementParagraph = "((?<statement>[^\\n]+)\\n)?"
23+
private static let uri = "\\nURI: (?<uri>(\(uriPattern))?)"
24+
private static let version = "\\nVersion: (?<version>1)"
25+
private static let chainId = "\\nChain ID: (?<chainId>[0-9a-fA-F]+)"
26+
private static let nonce = "\\nNonce: (?<nonce>[0-9a-fA-F]{8,})"
27+
private static let issuedAt = "\\nIssued At: (?<issuedAt>(\(datetimePattern)))"
28+
private static let expirationTime = "(\\nExpiration Time: (?<expirationTime>(\(datetimePattern))))?"
29+
private static let notBefore = "(\\nNot Before: (?<notBefore>(\(datetimePattern))))?"
30+
private static let requestId = "(\\nRequest ID: (?<requestId>[-._~!$&'()*+,;=:@%a-zA-Z0-9]*))?"
31+
private static let resourcesParagraph = "(\\nResources:(?<resources>(\\n- (\(uriPattern))?)+))?"
32+
33+
private static var eip4361Pattern: String {
34+
"^\(domain)\(address)\(statementParagraph)\(uri)\(version)\(chainId)\(nonce)\(issuedAt)\(expirationTime)\(notBefore)\(requestId)\(resourcesParagraph)$"
35+
}
36+
37+
/// `domain` is the RFC 3986 authority that is requesting the signing.
38+
public let domain: String
39+
/// `address` is the Ethereum address performing the signing conformant to capitalization encoded checksum specified in EIP-55 where applicable.
40+
public let address: EthereumAddress
41+
/// `statement` (optional) is a human-readable ASCII assertion that the user will sign, and it must not contain '\n' (the byte 0x0a).
42+
public let statement: String?
43+
/// `uri` is an RFC 3986 URI referring to the resource that is the subject of the signing (as in the subject of a claim).
44+
public let uri: URL
45+
/// `version` is the current version of the message, which MUST be 1 for this specification.
46+
public let version: BigUInt
47+
/// `chain-id` is the EIP-155 Chain ID to which the session is bound, and the network where Contract Accounts MUST be resolved.
48+
public let chainId: BigUInt
49+
/// `nonce` is a randomized token typically chosen by the relying party and used to prevent replay attacks, at least 8 alphanumeric characters.
50+
public let nonce: BigUInt
51+
/// `issued-at` is the ISO 8601 datetime string of the current time.
52+
public let issuedAt: Date
53+
/// `expiration-time` (optional) is the ISO 8601 datetime string that, if present, indicates when the signed authentication message is no longer valid.
54+
public let expirationTime: Date?
55+
/// `not-before` (optional) is the ISO 8601 datetime string that, if present, indicates when the signed authentication message will become valid.
56+
public let notBefore: Date?
57+
/// `request-id` (optional) is an system-specific identifier that may be used to uniquely refer to the sign-in request.
58+
public let requestId: String?
59+
/// `resources` (optional) is a list of information or references to information the user wishes to have resolved
60+
/// as part of authentication by the relying party. They are expressed as RFC 3986 URIs separated by "\n- " where \n is the byte 0x0a.
61+
public let resources: [URL]?
62+
63+
public init?(_ message: String) {
64+
let eip4361Regex = try! NSRegularExpression(pattern: EIP4361.eip4361Pattern)
65+
let groups = eip4361Regex.captureGroups(string: message)
66+
let dateFormatter = ISO8601DateFormatter()
67+
68+
guard let domain = groups["domain"],
69+
let rawAddress = groups["address"],
70+
let address = EthereumAddress(rawAddress),
71+
let rawUri = groups["uri"],
72+
let uri = URL(string: rawUri),
73+
let rawVersion = groups["version"],
74+
let version = BigUInt(rawVersion, radix: 10) ?? BigUInt(rawVersion, radix: 16),
75+
let rawChainId = groups["chainId"],
76+
let chainId = BigUInt(rawChainId, radix: 10) ?? BigUInt(rawChainId, radix: 16),
77+
let rawNonce = groups["nonce"],
78+
let nonce = BigUInt(rawNonce, radix: 10) ?? BigUInt(rawNonce, radix: 16),
79+
let rawIssuedAt = groups["issuedAt"],
80+
let issuedAt = dateFormatter.date(from: rawIssuedAt)
81+
else {
82+
return nil
83+
}
84+
85+
self.domain = domain
86+
self.address = address
87+
self.statement = groups["statement"]
88+
self.uri = uri
89+
self.version = version
90+
self.chainId = chainId
91+
self.nonce = nonce
92+
self.issuedAt = issuedAt
93+
expirationTime = dateFormatter.date(from: groups["expirationTime"] ?? "")
94+
notBefore = dateFormatter.date(from: groups["notBefore"] ?? "")
95+
requestId = groups["requestId"]
96+
if let rawResources = groups["resources"] {
97+
resources = rawResources.components(separatedBy: "\n- ").compactMap { URL(string: $0) }
98+
} else {
99+
resources = nil
100+
}
101+
}
102+
103+
public var description: String {
104+
var descriptionParts = [String]()
105+
descriptionParts.append("\(domain) wants you to sign in with your Ethereum account:")
106+
descriptionParts.append("\n\(address.address)")
107+
if let statement = statement {
108+
descriptionParts.append("\n\n\(statement)")
109+
}
110+
descriptionParts.append("\n\nURI: \(uri)")
111+
descriptionParts.append("\nVersion: \(version.description)")
112+
descriptionParts.append("\nChain ID: \(chainId.description)")
113+
descriptionParts.append("\nNonce: \(nonce.description)")
114+
let dateFormatter = ISO8601DateFormatter()
115+
descriptionParts.append("\nIssued At: \(dateFormatter.string(from: issuedAt))")
116+
if let expirationTime = expirationTime {
117+
descriptionParts.append("\nExpiration Time: \(dateFormatter.string(from: expirationTime))")
118+
}
119+
if let notBefore = notBefore {
120+
descriptionParts.append("\nNot Before: \(dateFormatter.string(from: notBefore))")
121+
}
122+
if let requestId = requestId {
123+
descriptionParts.append("\nRequest ID: \(requestId)")
124+
}
125+
if let resources = resources, !resources.isEmpty {
126+
descriptionParts.append("\nResources:")
127+
descriptionParts.append(contentsOf: resources.map { "\n- \($0.absoluteString)" })
128+
}
129+
return descriptionParts.joined()
130+
}
131+
}
132+
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// EIP4361Test.swift
3+
//
4+
// Created by JeneaVranceanu at 21.09.2022.
5+
//
6+
7+
import Foundation
8+
import XCTest
9+
import Core
10+
11+
@testable import web3swift
12+
13+
class EIP4361Test: XCTestCase {
14+
15+
/// Parsing Sign in with Ethereum message
16+
func test_EIP4361Parsing() {
17+
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:24Z\nExpiration Time: 2021-09-29T15:25:24Z\nNot Before: 2021-10-28T14:25:24Z\nRequest ID: random-request-id_STRING!@$%%&\nResources:\n- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/\n- https://example.com/my-web2-claim.json"
18+
guard let siweMessage = EIP4361(rawSiweMessage) else {
19+
XCTFail("Failed to parse SIWE message.")
20+
return
21+
}
22+
23+
let dateFormatter = ISO8601DateFormatter()
24+
XCTAssertEqual(siweMessage.domain, "service.invalid")
25+
XCTAssertEqual(siweMessage.address, EthereumAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")!)
26+
XCTAssertEqual(siweMessage.statement, "I accept the ServiceOrg Terms of Service: https://service.invalid/tos")
27+
XCTAssertEqual(siweMessage.uri, URL(string: "https://service.invalid/login")!)
28+
XCTAssertEqual(siweMessage.version, 1)
29+
XCTAssertEqual(siweMessage.chainId, 1)
30+
XCTAssertEqual(siweMessage.nonce, 32891756)
31+
XCTAssertEqual(siweMessage.issuedAt, dateFormatter.date(from: "2021-09-30T16:25:24Z")!)
32+
XCTAssertEqual(siweMessage.expirationTime, dateFormatter.date(from: "2021-09-29T15:25:24Z")!)
33+
XCTAssertEqual(siweMessage.notBefore, dateFormatter.date(from: "2021-10-28T14:25:24Z")!)
34+
XCTAssertEqual(siweMessage.requestId, "random-request-id_STRING!@$%%&")
35+
XCTAssertEqual(siweMessage.resources, [URL(string: "ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/")!,
36+
URL(string: "https://example.com/my-web2-claim.json")!])
37+
XCTAssertEqual(siweMessage.description, rawSiweMessage)
38+
}
39+
}

0 commit comments

Comments
 (0)