Skip to content

Commit dce0f36

Browse files
Merge pull request #615 from JeneaVranceanu/feat/siwe-eip4361
feat: EIP-4361 - or Sign in with Ethereum (SIWE)
2 parents 53f1ec5 + f83b5e7 commit dce0f36

File tree

5 files changed

+238
-57
lines changed

5 files changed

+238
-57
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
}

Sources/Core/Utility/String+Extension.swift

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,22 @@ extension String {
8484
return self
8585
}
8686

87-
func stripLeadingZeroes() -> String? {
88-
let hex = self.addHexPrefix()
89-
guard let matcher = try? NSRegularExpression(pattern: "^(?<prefix>0x)0*(?<end>[0-9a-fA-F]*)$", options: NSRegularExpression.Options.dotMatchesLineSeparators) else {return nil}
90-
let match = matcher.captureGroups(string: hex, options: NSRegularExpression.MatchingOptions.anchored)
91-
guard let prefix = match["prefix"] else {return nil}
92-
guard let end = match["end"] else {return nil}
93-
if (end != "") {
94-
return prefix + end
87+
/// Strips leading zeroes from a HEX string.
88+
/// ONLY HEX string format is supported.
89+
/// - Returns: string with stripped leading zeroes (and 0x prefix) or unchaged string.
90+
func stripLeadingZeroes() -> String {
91+
let hex = addHexPrefix()
92+
guard let matcher = try? NSRegularExpression(pattern: "^(?<prefix>0x)(?<leadingZeroes>0+)(?<end>[0-9a-fA-F]*)$",
93+
options: .dotMatchesLineSeparators)
94+
else {
95+
NSLog("stripLeadingZeroes(): failed to parse regex pattern.")
96+
return self
9597
}
96-
return "0x0"
98+
let match = matcher.captureGroups(string: hex, options: .anchored)
99+
guard match["leadingZeroes"] != nil,
100+
let prefix = match["prefix"],
101+
let end = match["end"] else { return self }
102+
return end != "" ? prefix + end : "0x0"
97103
}
98104

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

0 commit comments

Comments
 (0)