From f63633bda69c7b2f1e835b63701df87dc44941b6 Mon Sep 17 00:00:00 2001 From: Rodrigo Kreutz <8869678+rkreutz@users.noreply.github.com> Date: Sun, 19 Jun 2022 23:06:12 +0100 Subject: [PATCH 1/5] Implementing Sign-in with Ethereum --- web3sTests/ERC1271/ERC1271Tests.swift | 102 ++++ web3sTests/SIWE/SIWETests.swift | 47 ++ web3sTests/SIWE/SiweMessageTests.swift | 491 ++++++++++++++++++ web3sTests/SIWE/SiweVerifierTests.swift | 217 ++++++++ web3swift/src/ERC1271/ERC1271.swift | 48 ++ web3swift/src/ERC1271/ERC1271Error.swift | 12 + web3swift/src/ERC1271/ERC1271Functions.swift | 38 ++ web3swift/src/ERC1271/ERC1271Responses.swift | 57 ++ web3swift/src/Extensions/ByteExtensions.swift | 4 + .../EthereumAccount+SignSIWERequest.swift | 20 + web3swift/src/SIWE/SiweMessage+Codable.swift | 65 +++ web3swift/src/SIWE/SiweMessage+RegEx.swift | 73 +++ web3swift/src/SIWE/SiweMessage+String.swift | 94 ++++ .../src/SIWE/SiweMessage+Validation.swift | 66 +++ web3swift/src/SIWE/SiweMessage.swift | 81 +++ web3swift/src/SIWE/SiweVerifier.swift | 92 ++++ 16 files changed, 1507 insertions(+) create mode 100644 web3sTests/ERC1271/ERC1271Tests.swift create mode 100644 web3sTests/SIWE/SIWETests.swift create mode 100644 web3sTests/SIWE/SiweMessageTests.swift create mode 100644 web3sTests/SIWE/SiweVerifierTests.swift create mode 100644 web3swift/src/ERC1271/ERC1271.swift create mode 100644 web3swift/src/ERC1271/ERC1271Error.swift create mode 100644 web3swift/src/ERC1271/ERC1271Functions.swift create mode 100644 web3swift/src/ERC1271/ERC1271Responses.swift create mode 100644 web3swift/src/SIWE/EthereumAccount+SignSIWERequest.swift create mode 100644 web3swift/src/SIWE/SiweMessage+Codable.swift create mode 100644 web3swift/src/SIWE/SiweMessage+RegEx.swift create mode 100644 web3swift/src/SIWE/SiweMessage+String.swift create mode 100644 web3swift/src/SIWE/SiweMessage+Validation.swift create mode 100644 web3swift/src/SIWE/SiweMessage.swift create mode 100644 web3swift/src/SIWE/SiweVerifier.swift diff --git a/web3sTests/ERC1271/ERC1271Tests.swift b/web3sTests/ERC1271/ERC1271Tests.swift new file mode 100644 index 00000000..e24ab569 --- /dev/null +++ b/web3sTests/ERC1271/ERC1271Tests.swift @@ -0,0 +1,102 @@ +// +// ERC1271Tests.swift +// +// +// Created by Rodrigo Kreutz on 15/06/22. +// + +import XCTest +@testable import web3 + +final class ERC1271Tests: XCTestCase { + var mockClient: EthereumClientProtocol! + var erc1271: ERC1271! + + override func setUp() { + super.setUp() + self.mockClient = EthereumClient(url: URL(string: TestConfig.clientUrl)!) + self.erc1271 = ERC1271(client: self.mockClient) + } + + override func tearDown() { + super.tearDown() + } + + func testSuccesfulVerificationWithMagicNumberContract() async { + do { + let isValid = try await erc1271.isValidSignature( + contract: EthereumAddress("0x2bD85c85666a29bD453918B20b9E5ef7603d9007"), + messageHash: "0xb7755e72da7aca68df7d5ed5a832d027b624d56dab707d2b5257bbfc1bc5d4fd".web3.hexData!, + signature: "0x468732fa8210c6f8481a288a668bd6f40745e67c9640f82f7415b44e7ba280e13b6fce01acaaa4ab2fe8620a179ca99960620a014fdf74d9cf828912811c1b821b".web3.hexData! + ) + XCTAssertTrue(isValid) + } catch { + XCTFail("Failed with: \(error)") + } + } + + func testSuccessfulVerificationWithBooleanContract() async { + do { + let isValid = try await erc1271.isValidSignature( + contract: EthereumAddress("0x2505E4d4A76EC941591828311159552A832681D5"), + messageHash: "0xb7755e72da7aca68df7d5ed5a832d027b624d56dab707d2b5257bbfc1bc5d4fd".web3.hexData!, + signature: "0x468732fa8210c6f8481a288a668bd6f40745e67c9640f82f7415b44e7ba280e13b6fce01acaaa4ab2fe8620a179ca99960620a014fdf74d9cf828912811c1b821b".web3.hexData! + ) + XCTAssertTrue(isValid) + } catch { + XCTFail("Failed with: \(error)") + } + } + + func testFailedVerification() async { + do { + // Here the signature and the hash matches, but the contract will say is invalid cause the signer is not the owner of the contract + let isValid = try await erc1271.isValidSignature( + contract: EthereumAddress("0x2bD85c85666a29bD453918B20b9E5ef7603d9007"), + messageHash: "0x09bf2f6417d2bc487040194b78cbdd6b04f72ea12cf0014f83f4f228bed95ee4".web3.hexData!, + signature: "0xf85b9506180b11dc472278ff1e5fbb1e4b50baa3cadaec26b4b8179a55623f652a794c09b49227231f1144a62221453c854f09a986c1de1e19cdfff451e751b21c".web3.hexData! + ) + XCTAssertFalse(isValid) + } catch { + XCTFail("Failed with: \(error)") + } + + do { + // Here the signature and the hash don't match + let isValid = try await erc1271.isValidSignature( + contract: EthereumAddress("0x2bD85c85666a29bD453918B20b9E5ef7603d9007"), + messageHash: "0xb7755e72da7aca68df7d5ed5a832d027b624d56dab707d2b5257bbfc1bc5d4fd".web3.hexData!, + signature: "0xf85b9506180b11dc472278ff1e5fbb1e4b50baa3cadaec26b4b8179a55623f652a794c09b49227231f1144a62221453c854f09a986c1de1e19cdfff451e751b21c".web3.hexData! + ) + XCTAssertFalse(isValid) + } catch { + XCTFail("Failed with: \(error)") + } + } + + func testFailedVerificationWithBooleanContract() async { + do { + // Here the signature and the hash matches, but the contract will say is invalid cause the signer is not the owner of the contract + let isValid = try await erc1271.isValidSignature( + contract: EthereumAddress("0x2505E4d4A76EC941591828311159552A832681D5"), + messageHash: "0x09bf2f6417d2bc487040194b78cbdd6b04f72ea12cf0014f83f4f228bed95ee4".web3.hexData!, + signature: "0xf85b9506180b11dc472278ff1e5fbb1e4b50baa3cadaec26b4b8179a55623f652a794c09b49227231f1144a62221453c854f09a986c1de1e19cdfff451e751b21c".web3.hexData! + ) + XCTAssertFalse(isValid) + } catch { + XCTFail("Failed with: \(error)") + } + + do { + // Here the signature and the hash don't match + let isValid = try await erc1271.isValidSignature( + contract: EthereumAddress("0x2505E4d4A76EC941591828311159552A832681D5"), + messageHash: "0xb7755e72da7aca68df7d5ed5a832d027b624d56dab707d2b5257bbfc1bc5d4fd".web3.hexData!, + signature: "0xf85b9506180b11dc472278ff1e5fbb1e4b50baa3cadaec26b4b8179a55623f652a794c09b49227231f1144a62221453c854f09a986c1de1e19cdfff451e751b21c".web3.hexData! + ) + XCTAssertFalse(isValid) + } catch { + XCTFail("Failed with: \(error)") + } + } +} diff --git a/web3sTests/SIWE/SIWETests.swift b/web3sTests/SIWE/SIWETests.swift new file mode 100644 index 00000000..0172e769 --- /dev/null +++ b/web3sTests/SIWE/SIWETests.swift @@ -0,0 +1,47 @@ +// +// SIWETests.swift +// +// +// Created by Rodrigo Kreutz on 16/06/22. +// + +import XCTest +@testable import web3 + +final class SIWETests: XCTestCase { + + func testEndToEnd() async { + let verifier = SiweVerifier(client: EthereumClient(url: URL(string: TestConfig.clientUrl)!)) + let account = try! EthereumAccount.init(keyStorage: TestEthereumKeyStorage(privateKey: "0x4646464646464646464646464646464646464646464646464646464646464646")) + let message = try! SiweMessage( + """ + login.xyz wants you to sign in with your Ethereum account: + \(account.address.toChecksumAddress()) + + Please sign this 🙏 + + URI: https://login.xyz/demo#login + Version: 1 + Chain ID: 3 + Nonce: qwerty123456 + Issued At: \(SiweMessage.dateFormatter.string(from: Date())) + Expiration Time: \(SiweMessage.dateFormatter.string(from: Date(timeInterval: 60, since: Date()))) + Not Before: \(SiweMessage.dateFormatter.string(from: Date(timeInterval: -60, since: Date()))) + Request ID: some-request-id + Resources: + - https://docs.login.xyz + - https://login.xyz + """ + ) + + var signature: String = "" + XCTAssertNoThrow(signature = try account.signSIWERequest(message)) + var isValid = false + do { + isValid = try await verifier.verify(message: message, against: signature) + XCTAssertTrue(isValid) + } catch { + XCTFail("Error thrown while verifying signature: \(error)") + } + } +} diff --git a/web3sTests/SIWE/SiweMessageTests.swift b/web3sTests/SIWE/SiweMessageTests.swift new file mode 100644 index 00000000..e0b250a3 --- /dev/null +++ b/web3sTests/SIWE/SiweMessageTests.swift @@ -0,0 +1,491 @@ +// +// SiweMessageTests.swift +// +// +// Created by Rodrigo Kreutz on 13/06/22. +// + +import XCTest +@testable import web3 + +final class SiweMessageTests: XCTestCase { + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + return formatter + }() + + func testFullJsonDecoding() { + let json = """ + { + "domain": "login.xyz", + "address": "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + "statement": "You abide to our T&C", + "uri": "https://login.xyz/demo#login", + "version": "1", + "chainId": 1, + "nonce": "qwerty123456", + "issuedAt": "2022-06-13T01:10:30.023Z", + "expirationTime": "2022-07-13T01:10:29.000Z", + "notBefore": "2022-06-13T09:00:00.000Z", + "requestId": "sample-login-123", + "resources": [ + "https://login.xyz", + "https://docs.login.xyz" + ] + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(SiweMessageTests.dateFormatter) + var message: SiweMessage? + XCTAssertNoThrow(message = try decoder.decode(SiweMessage.self, from: json)) + XCTAssertEqual( + message, + try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: "You abide to our T&C", + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: Date(timeIntervalSince1970: 1_657_674_629.0), + notBefore: Date(timeIntervalSince1970: 1_655_110_800.0), + requestId: "sample-login-123", + resources: [ + URL(string: "https://login.xyz")!, + URL(string: "https://docs.login.xyz")! + ] + ) + ) + } + + func testMinimalJsonDecoding() { + let json = """ + { + "domain": "login.xyz", + "address": "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + "uri": "https://login.xyz/demo#login", + "version": "1", + "chainId": 1, + "nonce": "qwerty123456", + "issuedAt": "2022-06-13T01:10:30.023Z" + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(SiweMessageTests.dateFormatter) + var message: SiweMessage? + XCTAssertNoThrow(message = try decoder.decode(SiweMessage.self, from: json)) + XCTAssertEqual( + message, + try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: nil, + resources: nil + ) + ) + } + + func testFullJsonEncoding() { + let message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: "You abide to our T&C", + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: Date(timeIntervalSince1970: 1_657_674_629.0), + notBefore: Date(timeIntervalSince1970: 1_655_110_800.0), + requestId: "sample-login-123", + resources: [ + URL(string: "https://login.xyz")!, + URL(string: "https://docs.login.xyz")! + ] + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(SiweMessageTests.dateFormatter) + var messageData: Data? + XCTAssertNoThrow(messageData = try encoder.encode(message).asJson(with: [.prettyPrinted, .sortedKeys])) + XCTAssertEqual( + messageData, + """ + { + "domain": "login.xyz", + "address": "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + "statement": "You abide to our T&C", + "uri": "https://login.xyz/demo#login", + "version": "1", + "chainId": 1, + "nonce": "qwerty123456", + "issuedAt": "2022-06-13T01:10:30.023Z", + "expirationTime": "2022-07-13T01:10:29.000Z", + "notBefore": "2022-06-13T09:00:00.000Z", + "requestId": "sample-login-123", + "resources": [ + "https://login.xyz", + "https://docs.login.xyz" + ] + } + """.data(using: .utf8)!.asJson(with: [.prettyPrinted, .sortedKeys]) + ) + } + + func testMinimalJsonEncoding() { + let message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: nil, + resources: nil + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(SiweMessageTests.dateFormatter) + var messageData: Data? + XCTAssertNoThrow(messageData = try encoder.encode(message).asJson(with: [.prettyPrinted, .sortedKeys])) + XCTAssertEqual( + messageData, + """ + { + "domain": "login.xyz", + "address": "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + "uri": "https://login.xyz/demo#login", + "version": "1", + "chainId": 1, + "nonce": "qwerty123456", + "issuedAt": "2022-06-13T01:10:30.023Z" + } + """.data(using: .utf8)!.asJson(with: [.prettyPrinted, .sortedKeys]) + ) + } + + func testFullMessageString() { + let message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: "You abide to our T&C", + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: Date(timeIntervalSince1970: 1_657_674_629.0), + notBefore: Date(timeIntervalSince1970: 1_655_110_800.0), + requestId: "sample-login-123", + resources: [ + URL(string: "https://login.xyz")!, + URL(string: "https://docs.login.xyz")! + ] + ) + + XCTAssertEqual( + "\(message)", + """ + login.xyz wants you to sign in with your Ethereum account: + 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F + + You abide to our T&C + + URI: https://login.xyz/demo#login + Version: 1 + Chain ID: 1 + Nonce: qwerty123456 + Issued At: 2022-06-13T01:10:30.023Z + Expiration Time: 2022-07-13T01:10:29.000Z + Not Before: 2022-06-13T09:00:00.000Z + Request ID: sample-login-123 + Resources: + - https://login.xyz + - https://docs.login.xyz + """ + ) + } + + func testMinimalMessageString() { + let message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: nil, + resources: nil + ) + + XCTAssertEqual( + "\(message)", + """ + login.xyz wants you to sign in with your Ethereum account: + 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F + + + URI: https://login.xyz/demo#login + Version: 1 + Chain ID: 1 + Nonce: qwerty123456 + Issued At: 2022-06-13T01:10:30.023Z + """ + ) + } + + func testFullMessageRegExParsing() { + let message = try! SiweMessage(fromStringUsingRegEx: """ + login.xyz wants you to sign in with your Ethereum account: + 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F + + You abide to our T&C + + URI: https://login.xyz/demo#login + Version: 1 + Chain ID: 1 + Nonce: qwerty123456 + Issued At: 2022-06-13T01:10:30.023Z + Expiration Time: 2022-07-13T01:10:29.000Z + Not Before: 2022-06-13T09:00:00.000Z + Request ID: sample-login-123 + Resources: + - https://login.xyz + - https://docs.login.xyz + """ + ) + + XCTAssertEqual( + message, + try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: "You abide to our T&C", + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: Date(timeIntervalSince1970: 1_657_674_629.0), + notBefore: Date(timeIntervalSince1970: 1_655_110_800.0), + requestId: "sample-login-123", + resources: [ + URL(string: "https://login.xyz")!, + URL(string: "https://docs.login.xyz")! + ] + ) + ) + } + + func testMinimalMessageRegExParsing() { + let message = try! SiweMessage(fromStringUsingRegEx: """ + login.xyz wants you to sign in with your Ethereum account: + 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F + + + URI: https://login.xyz/demo#login + Version: 1 + Chain ID: 1 + Nonce: qwerty123456 + Issued At: 2022-06-13T01:10:30.023Z + """ + ) + + XCTAssertEqual( + message, + try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: nil, + resources: nil + ) + ) + } + + func testDomainValidation() { + var message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: nil, + resources: nil + ) + + XCTAssertNoThrow(try message.validate()) + + message.domain = "" + + XCTAssertThrowsError(try message.validate()) + } + + func testAddressValidation() { + var message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: nil, + resources: nil + ) + + XCTAssertNoThrow(try message.validate()) + + message.address = "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F".lowercased() + + XCTAssertThrowsError(try message.validate()) + } + + func testVersionValidation() { + var message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: nil, + resources: nil + ) + + XCTAssertNoThrow(try message.validate()) + + message.version = "2" + + XCTAssertThrowsError(try message.validate()) + } + + func testChainIdValidation() { + var message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: nil, + resources: nil + ) + + XCTAssertNoThrow(try message.validate()) + + message.chainId = 0 + + XCTAssertThrowsError(try message.validate()) + + message.chainId = -1 + + XCTAssertThrowsError(try message.validate()) + } + + func testNonceValidation() { + var message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qWeRtY123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: nil, + resources: nil + ) + + XCTAssertNoThrow(try message.validate()) + + message.nonce = "qwerty" + + XCTAssertThrowsError(try message.validate()) + + message.nonce = "qwerty123$#@" + + XCTAssertThrowsError(try message.validate()) + } + + func testRequestIdValidation() { + var message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: nil, + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qWeRtY123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: nil, + notBefore: nil, + requestId: "some-request-id", + resources: nil + ) + + XCTAssertNoThrow(try message.validate()) + + message.requestId = "" + + XCTAssertThrowsError(try message.validate()) + } +} + +private extension Data { + func asJson(with writingOptions: JSONSerialization.WritingOptions) -> Data { + guard + let object = try? JSONSerialization.jsonObject(with: self, options: []), + let data = try? JSONSerialization.data(withJSONObject: object, options: writingOptions) + else { return self } + return data + } +} diff --git a/web3sTests/SIWE/SiweVerifierTests.swift b/web3sTests/SIWE/SiweVerifierTests.swift new file mode 100644 index 00000000..08143fe2 --- /dev/null +++ b/web3sTests/SIWE/SiweVerifierTests.swift @@ -0,0 +1,217 @@ +// +// SiweVerifierTests.swift +// +// +// Created by Rodrigo Kreutz on 15/06/22. +// + +import XCTest +@testable import web3 + +final class SiweVerifierTests: XCTestCase { + let client: EthereumClient = EthereumClient(url: URL(string: TestConfig.clientUrl)!) + + func testNetworkVerification() async { + let verifier = SiweVerifier(client: client, dateProvider: { Date(timeIntervalSince1970: 1_655_110_800.0) }) + let message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: "You abide to our T&C", + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 1, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: Date(timeIntervalSince1970: 1_657_674_629.0), + notBefore: Date(timeIntervalSince1970: 1_655_110_800.0), + requestId: "sample-login-123", + resources: [ + URL(string: "https://login.xyz")!, + URL(string: "https://docs.login.xyz")! + ] + ) + + do { + _ = try await verifier.verify(message: message, against: "") + XCTFail("Should have thrown an error") + } catch SiweVerifier.Error.differentNetwork { + // Success + } catch { + XCTFail("Failed with a different error than expected") + } + } + + func testNotBeforeVerification() async { + let verifier = SiweVerifier(client: client, dateProvider: { Date(timeIntervalSince1970: 1_655_110_799.9) }) + let message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: "You abide to our T&C", + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 3, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: Date(timeIntervalSince1970: 1_657_674_629.0), + notBefore: Date(timeIntervalSince1970: 1_655_110_800.0), + requestId: "sample-login-123", + resources: [ + URL(string: "https://login.xyz")!, + URL(string: "https://docs.login.xyz")! + ] + ) + + do { + _ = try await verifier.verify(message: message, against: "") + XCTFail("Should have thrown an error") + } catch SiweVerifier.Error.messageIsNotActiveYet { + // Success + } catch { + XCTFail("Failed with a different error than expected") + } + } + + func testExpirationTimeVerification() async { + let verifier = SiweVerifier(client: client, dateProvider: { Date(timeIntervalSince1970: 1_657_674_629.0) }) + let message = try! SiweMessage( + domain: "login.xyz", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + statement: "You abide to our T&C", + uri: URL(string: "https://login.xyz/demo#login")!, + version: "1", + chainId: 3, + nonce: "qwerty123456", + issuedAt: Date(timeIntervalSince1970: 1_655_082_630.023), + expirationTime: Date(timeIntervalSince1970: 1_657_674_629.0), + notBefore: Date(timeIntervalSince1970: 1_655_110_800.0), + requestId: "sample-login-123", + resources: [ + URL(string: "https://login.xyz")!, + URL(string: "https://docs.login.xyz")! + ] + ) + + do { + _ = try await verifier.verify(message: message, against: "") + XCTFail("Should have thrown an error") + } catch SiweVerifier.Error.messageIsExpired { + // Success + } catch { + XCTFail("Failed with a different error than expected") + } + } + + func testSignatureVerificationSuccess() async throws { + let verifier = SiweVerifier(client: client, dateProvider: { Date(timeIntervalSince1970: 1_655_082_630.023) }) + do { + let isVerified = try await verifier.verify( + """ + login.xyz wants you to sign in with your Ethereum account: + 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F + + Please sign this 🙏 + + URI: https://login.xyz/demo#login + Version: 1 + Chain ID: 3 + Nonce: qwerty123456 + Issued At: 2022-06-16T12:09:07.937Z + Request ID: some-request-id + Resources: + - https://docs.login.xyz + - https://login.xyz + """, + against: "0x14ff8392ce953be96f3e57d8519d94f4b19f98cf42d0dca8961c3fa70a12c9857e95f4732839d39e883d26abf0fcdd4602f50acde7721274ea4c31f26981e4b41b" + ) + XCTAssertTrue(isVerified) + } catch { + XCTFail("Failed with: \(error)") + } + } + + func testSignatureVerificationFailure() async throws { + let verifier = SiweVerifier(client: client, dateProvider: { Date(timeIntervalSince1970: 1_655_082_630.023) }) + do { + let isVerified = try await verifier.verify( + """ + login.xyz wants you to sign in with your Ethereum account: + 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F + + Please sign this 🙏 + + URI: https://login.xyz/demo#login + Version: 1 + Chain ID: 3 + Nonce: qwerty123456 + Issued At: 2022-06-16T12:09:07.937Z + Request ID: some-request-id + Resources: + - https://docs.login.xyz + - https://login.xyz + """, + against: "0x60e700bb8c14da9bc751aee3cb338a763ad9425e7893bd49393fec9f540e9cee1023c42e06989d0b1c04d84b88c62a872073e60218d2c0bc900b5f7f186096611c" + ) + XCTAssertFalse(isVerified) + } catch { + XCTFail("Failed with: \(error)") + } + } + + func testSignatureVerificationSuccessUsingERC1271() async throws { + let verifier = SiweVerifier(client: client, dateProvider: { Date(timeIntervalSince1970: 1_655_082_630.023) }) + do { + // Notice that the Ethereum account in the message is actually the address of the ERC1271 contract + let isVerified = try await verifier.verify( + """ + login.xyz wants you to sign in with your Ethereum account: + 0x2bD85c85666a29bD453918B20b9E5ef7603d9007 + + Please sign this 🙏 + + URI: https://login.xyz/demo#login + Version: 1 + Chain ID: 3 + Nonce: qwerty123456 + Issued At: 2022-06-16T12:09:07.937Z + Request ID: some-request-id + Resources: + - https://docs.login.xyz + - https://login.xyz + """, + against: "0x0217550201eb35af7048e32dbf05201de1440eb079e1046f16cce27463fb5bf56f8c9c33a93845b1e891afdcf60608255433b18435c9cdabca6625fb1bcc41841b" + ) + XCTAssertTrue(isVerified) + } catch { + XCTFail("Failed with: \(error)") + } + } + + func testSignatureVerificationFailureUsingERC1271() async throws { + let verifier = SiweVerifier(client: client, dateProvider: { Date(timeIntervalSince1970: 1_655_082_630.023) }) + do { + // Notice that the Ethereum account in the message is actually the address of the ERC1271 contract + let isVerified = try await verifier.verify( + """ + login.xyz wants you to sign in with your Ethereum account: + 0x2bD85c85666a29bD453918B20b9E5ef7603d9007 + + Please sign this 🙏 + + URI: https://login.xyz/demo#login + Version: 1 + Chain ID: 3 + Nonce: qwerty123456 + Issued At: 2022-06-16T12:09:07.937Z + Request ID: some-request-id + Resources: + - https://docs.login.xyz + - https://login.xyz + """, + against: "0x60e700bb8c14da9bc751aee3cb338a763ad9425e7893bd49393fec9f540e9cee1023c42e06989d0b1c04d84b88c62a872073e60218d2c0bc900b5f7f186096611c" + ) + XCTAssertFalse(isVerified) + } catch { + XCTFail("Failed with: \(error)") + } + } +} diff --git a/web3swift/src/ERC1271/ERC1271.swift b/web3swift/src/ERC1271/ERC1271.swift new file mode 100644 index 00000000..04285f7d --- /dev/null +++ b/web3swift/src/ERC1271/ERC1271.swift @@ -0,0 +1,48 @@ +// +// ERC1271.swift +// +// +// Created by Rodrigo Kreutz on 15/06/22. +// + +import Foundation +import BigInt + +public protocol ERC1271Protocol { + init(client: EthereumClientProtocol) + + func isValidSignature(contract: EthereumAddress, messageHash: Data, signature: Data, completionHandler: @escaping(Result) -> Void) + + // async + func isValidSignature(contract: EthereumAddress, messageHash: Data, signature: Data) async throws -> Bool +} + +public class ERC1271: ERC1271Protocol { + let client: EthereumClientProtocol + + required public init(client: EthereumClientProtocol) { + self.client = client + } + + public func isValidSignature(contract: EthereumAddress, messageHash: Data, signature: Data, completionHandler: @escaping (Result) -> Void) { + do { + let function = try ERC1271Functions.isValidSignature(contract: contract, message: messageHash, signature: signature) + function.call(withClient: self.client, responseType: ERC1271Responses.isValidResponse.self) { result in + switch result { + case .success(let response): + completionHandler(.success(response.isValid)) + case .failure(let error): + completionHandler(.failure(error)) + } + } + } catch { + completionHandler(.failure(error)) + } + } + + public func isValidSignature(contract: EthereumAddress, messageHash: Data, signature: Data) async throws -> Bool { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + isValidSignature(contract: contract, messageHash: messageHash, signature: signature, completionHandler: continuation.resume) + } + } +} diff --git a/web3swift/src/ERC1271/ERC1271Error.swift b/web3swift/src/ERC1271/ERC1271Error.swift new file mode 100644 index 00000000..62e7404f --- /dev/null +++ b/web3swift/src/ERC1271/ERC1271Error.swift @@ -0,0 +1,12 @@ +// +// ERC1271Error.swift +// +// +// Created by Rodrigo Kreutz on 15/06/22. +// + +import Foundation + +public enum ERC1271Error: Error { + case invalidInput +} diff --git a/web3swift/src/ERC1271/ERC1271Functions.swift b/web3swift/src/ERC1271/ERC1271Functions.swift new file mode 100644 index 00000000..62bdaffb --- /dev/null +++ b/web3swift/src/ERC1271/ERC1271Functions.swift @@ -0,0 +1,38 @@ +// +// ERC1271Functions.swift +// +// +// Created by Rodrigo Kreutz on 15/06/22. +// + +import Foundation +import BigInt + +public enum ERC1271Functions { + + public struct isValidSignature: ABIFunction { + + public static let name = "isValidSignature" + public let gasPrice: BigUInt? = nil + public let gasLimit: BigUInt? = nil + public let from: EthereumAddress? = nil + public var contract: EthereumAddress + + public let message: Data + public let signature: Data + + public init(contract: EthereumAddress, + message: Data, + signature: Data) throws { + guard message.count == 32 && signature.count == 65 else { throw ERC1271Error.invalidInput } + self.contract = contract + self.message = message + self.signature = signature + } + + public func encode(to encoder: ABIFunctionEncoder) throws { + try encoder.encode(message, staticSize: 32) + try encoder.encode(signature) + } + } +} diff --git a/web3swift/src/ERC1271/ERC1271Responses.swift b/web3swift/src/ERC1271/ERC1271Responses.swift new file mode 100644 index 00000000..84015275 --- /dev/null +++ b/web3swift/src/ERC1271/ERC1271Responses.swift @@ -0,0 +1,57 @@ +// +// ERC1271.swift +// +// +// Created by Rodrigo Kreutz on 15/06/22. +// + +import Foundation + +public enum ERC1271Responses { + + public struct isValidResponse: ABIResponse { + + // bytes4(keccak256("isValidSignature(bytes32,bytes)") + static let MAGICVALUE = Data(hex: "0x1626ba7e") + + public static var types: [ABIType.Type] = [EitherBoolOrData4.self] + + public let isValid: Bool + + public init?(values: [ABIDecoder.DecodedValue]) throws { + // It seems there are some confusion on the original EIP thread on github. + // Some reference the return type as bool and others as byte4 (with the magic value) + // so we'll try parsing both types, though byte4 parsing is what's actually + // on the finalised document. + switch try values[0].decoded() as EitherBoolOrData4 { + case .bool(let bool): + self.isValid = bool + case .data(let data): + self.isValid = data == Self.MAGICVALUE + } + } + } + + // This will map the result to either a Bool value or a Data with 4 bytes + private enum EitherBoolOrData4: ABIType { + + // Both cases return 32 bytes of data + public static var rawType: ABIRawType { .FixedBytes(32) } + + public static var parser: ParserFunction { + return { data in + switch data.first ?? "" { + case "0x0000000000000000000000000000000000000000000000000000000000000000": + return EitherBoolOrData4.bool(false) + case "0x0000000000000000000000000000000000000000000000000000000000000001": + return EitherBoolOrData4.bool(true) + case let data: + return EitherBoolOrData4.data(try ABIDecoder.decode(data, to: Data.self).web3.bytes4) + } + } + } + + case bool(Bool) + case data(Data) + } +} diff --git a/web3swift/src/Extensions/ByteExtensions.swift b/web3swift/src/Extensions/ByteExtensions.swift index bc32b779..d42c21dc 100644 --- a/web3swift/src/Extensions/ByteExtensions.swift +++ b/web3swift/src/Extensions/ByteExtensions.swift @@ -100,6 +100,10 @@ public extension Web3Extensions where Base == Data { var bytes4: Data { return base.prefix(4) } + + var bytes32: Data { + return base.prefix(32) + } } public extension String { diff --git a/web3swift/src/SIWE/EthereumAccount+SignSIWERequest.swift b/web3swift/src/SIWE/EthereumAccount+SignSIWERequest.swift new file mode 100644 index 00000000..179f0be1 --- /dev/null +++ b/web3swift/src/SIWE/EthereumAccount+SignSIWERequest.swift @@ -0,0 +1,20 @@ +// +// EthereumAccount+SignSIWERequest.swift +// +// +// Created by Rodrigo Kreutz on 16/06/22. +// + +import Foundation + +extension EthereumAccount { + func signSIWERequest(_ message: String) throws -> String { + let message = try SiweMessage(message) + return try signSIWERequest(message) + } + + func signSIWERequest(_ message: SiweMessage) throws -> String { + guard let data = "\(message)".data(using: .utf8) else { throw EthereumAccountError.signError } + return try signMessage(message: data) + } +} diff --git a/web3swift/src/SIWE/SiweMessage+Codable.swift b/web3swift/src/SIWE/SiweMessage+Codable.swift new file mode 100644 index 00000000..4e77836f --- /dev/null +++ b/web3swift/src/SIWE/SiweMessage+Codable.swift @@ -0,0 +1,65 @@ +// +// SiweMessage+Codable.swift +// +// +// Created by Rodrigo Kreutz on 13/06/22. +// + +import Foundation + +extension SiweMessage: Codable { + + enum CodingKeys: String, CaseIterable, CodingKey { + case domain + case address + case statement + case uri + case version + case chainId + case nonce + case issuedAt + case expirationTime + case notBefore + case requestId + case resources + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.domain = try container.decode(String.self, forKey: .domain) + self.address = try container.decode(String.self, forKey: .address) + self.statement = try container.decodeIfPresent(String.self, forKey: .statement) + self.uri = try container.decode(URL.self, forKey: .uri) + self.version = try container.decode(String.self, forKey: .version) + if let chainIdString = try? container.decode(String.self, forKey: .chainId), + let chainId = Int(chainIdString) { + self.chainId = chainId + } else { + self.chainId = try container.decode(Int.self, forKey: .chainId) + } + self.nonce = try container.decode(String.self, forKey: .nonce) + self.issuedAt = try container.decode(Date.self, forKey: .issuedAt) + self.expirationTime = try container.decodeIfPresent(Date.self, forKey: .expirationTime) + self.notBefore = try container.decodeIfPresent(Date.self, forKey: .notBefore) + self.requestId = try container.decodeIfPresent(String.self, forKey: .requestId) + self.resources = try container.decodeIfPresent([URL].self, forKey: .resources) + + try validate() + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(domain, forKey: .domain) + try container.encode(address, forKey: .address) + try container.encodeIfPresent(statement, forKey: .statement) + try container.encode(uri, forKey: .uri) + try container.encode(version, forKey: .version) + try container.encode(chainId, forKey: .chainId) + try container.encode(nonce, forKey: .nonce) + try container.encode(issuedAt, forKey: .issuedAt) + try container.encodeIfPresent(expirationTime, forKey: .expirationTime) + try container.encodeIfPresent(notBefore, forKey: .notBefore) + try container.encodeIfPresent(requestId, forKey: .requestId) + try container.encodeIfPresent(resources, forKey: .resources) + } +} diff --git a/web3swift/src/SIWE/SiweMessage+RegEx.swift b/web3swift/src/SIWE/SiweMessage+RegEx.swift new file mode 100644 index 00000000..61dd694d --- /dev/null +++ b/web3swift/src/SIWE/SiweMessage+RegEx.swift @@ -0,0 +1,73 @@ +// +// SiweMessage+RegEx.swift +// +// +// Created by Rodrigo Kreutz on 13/06/22. +// + +import Foundation + +extension SiweMessage { + + /// Errors thrown while trying to parse a SIWE string message using RegEx + enum RegExError: Swift.Error { + + /// Error thrown when no absolute matches were found in the message + case noMatches + /// Error thrown in case we couldn't create a JSON object from parsed values + case invalidJson + } + + /// Regular expressions were taken and adapted from SIWE maintainer's [main TypeScript repo](https://github.com/spruceid/siwe/blob/main/packages/siwe-parser/lib/regex.ts) + /// + /// Check [the docs web page](https://docs.login.xyz) for more info + private enum RegEx { + + static let domain = "(?<\(CodingKeys.domain.rawValue)>([^?#]*)) wants you to sign in with your Ethereum account:" + static let address = "\n(?<\(CodingKeys.address.rawValue)>0x[a-zA-Z0-9]{40})\n\n" + static let statement = "((?<\(CodingKeys.statement.rawValue)>[^\n]+)\n)?" + static let uri = "(([^:?#]+):)?(([^?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))" + static let uriLine = "\nURI: (?<\(CodingKeys.uri.rawValue)>\(uri)?)" + static let version = "\nVersion: (?<\(CodingKeys.version.rawValue)>1)" + static let chainId = "\nChain ID: (?<\(CodingKeys.chainId.rawValue)>[0-9]+)" + static let nonce = "\nNonce: (?<\(CodingKeys.nonce.rawValue)>[a-zA-Z0-9]{8,})" + static let dateTime = "([0-9]+)-(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]))" + static let issuedAt = "\nIssued At: (?<\(CodingKeys.issuedAt.rawValue)>\(dateTime))" + static let expirationTime = "(\nExpiration Time: (?<\(CodingKeys.expirationTime.rawValue)>\(dateTime)))?" + static let notBefore = "(\nNot Before: (?<\(CodingKeys.notBefore.rawValue)>\(dateTime)))?" + static let requestId = "(\nRequest ID: (?<\(CodingKeys.requestId.rawValue)>[-._~!$&'()*+,;=:@%a-zA-Z0-9]*))?" + static let resources = "(\nResources:(?<\(CodingKeys.resources.rawValue)>(\n- \(uri)?)+))?" + static let message = "^\(domain)\(address)\(statement)\(uriLine)\(version)\(chainId)\(nonce)\(issuedAt)\(expirationTime)\(notBefore)\(requestId)\(resources)$" + } + + init(fromStringUsingRegEx string: String) throws { + guard let regex = try? NSRegularExpression(pattern: RegEx.message, options: []) else { + assertionFailure("Regular expression is invalid") + throw RegExError.noMatches + } + let range = NSRange(string.startIndex ..< string.endIndex, in: string) + guard let match = regex.firstMatch(in: string, options: [], range: range) else { throw RegExError.noMatches } + + var messageDict: [String: Any] = [:] + for field in CodingKeys.allCases { + let fieldNSRange = match.range(withName: field.rawValue) + if fieldNSRange.location != NSNotFound, + let fieldRange = Range(fieldNSRange, in: string) { + let value = string[fieldRange] + if field == .resources { + let resources = value.components(separatedBy: "\n- ").filter { !$0.isEmpty } + messageDict[field.rawValue] = resources + } else { + messageDict[field.rawValue] = value + } + } + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(SiweMessage.dateFormatter) + + guard let jsonData = try? JSONSerialization.data(withJSONObject: messageDict, options: []) else { throw RegExError.invalidJson } + + self = try decoder.decode(SiweMessage.self, from: jsonData) + } +} diff --git a/web3swift/src/SIWE/SiweMessage+String.swift b/web3swift/src/SIWE/SiweMessage+String.swift new file mode 100644 index 00000000..04f331f6 --- /dev/null +++ b/web3swift/src/SIWE/SiweMessage+String.swift @@ -0,0 +1,94 @@ +// +// SiweMessage+LosslessStringConvertible.swift +// +// +// Created by Rodrigo Kreutz on 13/06/22. +// + +import Foundation + +extension SiweMessage: CustomStringConvertible { + + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + return formatter + }() + + /// `SiweMessage` can be easily converted into a SIWE string message simply by using this property (which in turn is also + /// used natively when doing `"\(message)"`) + /// + /// The SIWE string message follows [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361), and looks something like the following: + /// + /// ``` + /// service.org wants you to sign in with your Ethereum account: + /// 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 + /// + /// I accept the ServiceOrg Terms of Service: https://service.org/tos + /// + /// URI: https://service.org/login + /// Version: 1 + /// Chain ID: 1 + /// Nonce: 32891756 + /// Issued At: 2021-09-30T16:25:24Z + /// Resources: + /// - ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/ + /// - https://example.com/my-web2-claim.json + /// ``` + public var description: String { + var fields = [ + "\(domain) wants you to sign in with your Ethereum account:", + "\(address)", + "\(statement.map { "\n\($0)\n" } ?? "\n")", + "URI: \(uri.absoluteString)", + "Version: \(version)", + "Chain ID: \(chainId)", + "Nonce: \(nonce)", + "Issued At: \(SiweMessage.dateFormatter.string(from: issuedAt))" + ] + + if let expirationTime = expirationTime { + fields.append("Expiration Time: \(SiweMessage.dateFormatter.string(from: expirationTime))") + } + if let notBefore = notBefore { + fields.append("Not Before: \(SiweMessage.dateFormatter.string(from: notBefore))") + } + if let requestId = requestId { + fields.append("Request ID: \(requestId)") + } + if let resources = resources { + fields.append("Resources:") + fields.append(contentsOf: resources.map { "- \($0.absoluteString)" }) + } + + return fields.joined(separator: "\n") + } + + /// `SiweMessage` can easily be created from a SIWE string message + /// + /// The SIWE string message follows [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361), and looks something like the following: + /// + /// ``` + /// service.org wants you to sign in with your Ethereum account: + /// 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 + /// + /// I accept the ServiceOrg Terms of Service: https://service.org/tos + /// + /// URI: https://service.org/login + /// Version: 1 + /// Chain ID: 1 + /// Nonce: 32891756 + /// Issued At: 2021-09-30T16:25:24Z + /// Resources: + /// - ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/ + /// - https://example.com/my-web2-claim.json + /// ``` + /// - Parameter description: the SIWE string message following EIP-4361 standard. + /// - Throws: `SiweMessage.RegExError` if an error occured while parsing the string; + /// `SiweMessage.ValidationError` in case regex parsing was successful but data in the message was invalid; + /// might throw `DecodingError` since we use `Decodable` to transform parsed values into `SiweMessage`. + public init(_ description: String) throws { + try self.init(fromStringUsingRegEx: description) + } +} diff --git a/web3swift/src/SIWE/SiweMessage+Validation.swift b/web3swift/src/SIWE/SiweMessage+Validation.swift new file mode 100644 index 00000000..d502fdb9 --- /dev/null +++ b/web3swift/src/SIWE/SiweMessage+Validation.swift @@ -0,0 +1,66 @@ +// +// SiweMessage+Validation.swift +// +// +// Created by Rodrigo Kreutz on 13/06/22. +// + +import Foundation + +extension SiweMessage { + + /// Errors thrown when checking if a `SiweMessage` is valid or not + public enum ValidationError: Swift.Error { + /// The domain provided is not valid, should be a host name + case invalidDomain + /// The EVM address is invalid, should be `0x` prefixed and EIP-55 encoded (upper/lowercase encoded) + case invalidAddress + /// The version of the message is invalid + case invalidVersion + /// The chain id of the message is invalid + case invalidChainId + /// The nonce of the message is invalid + case invalidNonce + /// The request id of the message is invalid + case invalidRequestId + } + + func validate() throws { + + guard + !domain.isEmpty, + domain.matches(regex: "[^#?]*") + else { throw ValidationError.invalidDomain } + + guard address.isEIP55Address else { throw ValidationError.invalidAddress } + + guard version == "1" else { throw ValidationError.invalidVersion } + + guard chainId > 0 else { throw ValidationError.invalidChainId } + + guard + nonce.count >= 8, + nonce.matches(regex: "[a-zA-Z0-9]{8,}") + else { throw ValidationError.invalidNonce } + + if let requestId = requestId { + guard + !requestId.isEmpty, + requestId.matches(regex: "[-._~!$&'()*+,;=:@%a-zA-Z0-9]*") + else { throw ValidationError.invalidRequestId } + } + } +} + +private extension String { + + func matches(regex: String) -> Bool { + let match = range(of: regex, options: .regularExpression, range: startIndex ..< endIndex, locale: Locale(identifier: "en_US_POSIX")) + return match == startIndex ..< endIndex + } + + var isEIP55Address: Bool { + let address = EthereumAddress(self) + return self.compare(address.toChecksumAddress()) == .orderedSame + } +} diff --git a/web3swift/src/SIWE/SiweMessage.swift b/web3swift/src/SIWE/SiweMessage.swift new file mode 100644 index 00000000..01d2f8da --- /dev/null +++ b/web3swift/src/SIWE/SiweMessage.swift @@ -0,0 +1,81 @@ +// +// SiweMessage.swift +// +// +// Created by Rodrigo Kreutz on 13/06/22. +// + +import Foundation + +/// Sign-in with Ethereum (SIWE) base message struct +/// +/// For more information on SIWE check out https://docs.login.xyz +public struct SiweMessage: Hashable { + + /// RFC 4501 dns authority that is requesting the signing. + public var domain: String + + /// Ethereum address performing the signing conformant to capitalization encoded checksum specified in EIP-55 where applicable. + public var address: String + + /// Human-readable ASCII assertion that the user will sign, and it must not contain `\n`. + public var statement: String? + + /// RFC 3986 URI referring to the resource that is the subject of the signing (as in the __subject__ of a claim). + public var uri: URL + + /// Current version of the message. + public var version: String + + ///EIP-155 Chain ID to which the session is bound, and the network where Contract Accounts must be resolved. + public var chainId: Int + + /// Randomized token used to prevent replay attacks, at least 8 alphanumeric characters. + public var nonce: String + + /// Datetime which the message was issued at/ + public var issuedAt: Date + + /// Datetime which, if present, indicates when the signed message is no longer valid. + public var expirationTime: Date? + + /// Datetime which, if present, indicates when the signed message will become valid. + public var notBefore: Date? + + /// System-specific identifier that may be used to uniquely refer to the sign-in request. + public var requestId: String? + + /// List of information or references to information the user wishes to have resolved as part of authentication by the relying party. + /// They are expressed as RFC 3986 URIs separated by `\n- `. + public var resources: [URL]? + + public init( + domain: String, + address: String, + statement: String?, + uri: URL, + version: String, + chainId: Int, + nonce: String, + issuedAt: Date, + expirationTime: Date?, + notBefore: Date?, + requestId: String?, + resources: [URL]? + ) throws { + self.domain = domain + self.address = address + self.statement = statement + self.uri = uri + self.version = version + self.chainId = chainId + self.nonce = nonce + self.issuedAt = issuedAt + self.expirationTime = expirationTime + self.notBefore = notBefore + self.requestId = requestId + self.resources = resources + + try validate() + } +} diff --git a/web3swift/src/SIWE/SiweVerifier.swift b/web3swift/src/SIWE/SiweVerifier.swift new file mode 100644 index 00000000..f9fe242e --- /dev/null +++ b/web3swift/src/SIWE/SiweVerifier.swift @@ -0,0 +1,92 @@ +// +// SiweVerifier.swift +// +// +// Created by Rodrigo Kreutz on 14/06/22. +// + +import Foundation +import BigInt + +/// An object which will verify if a given `SiweMessage` and signature match with the EVM address provided +public class SiweVerifier { + + /// Errors thrown when verifing a given message agains a signature + public enum Error: Swift.Error { + /// The provided message is from a different network than the client's. + case differentNetwork + /// The provided message has a `notBefore` field set and the verification time is before that time + case messageIsNotActiveYet + /// The provided message has a `expirationTime` field set and the verification time is after or at that time + case messageIsExpired + /// Failed to fetch `utf8` data from the message string + case invalidMessageData + /// Failed to fetch the hex data from the signature + case invalidSignature + } + + private let client: EthereumClientProtocol + private let dateProvider: () -> Date + + public init(client: EthereumClientProtocol, dateProvider: @escaping () -> Date = Date.init) { + self.client = client + self.dateProvider = dateProvider + } + + /// Verifies if a given EIP-4361 string message was signed by the address in the message. + /// - Parameters: + /// - message: the EIP-4361 string message + /// - signature: the hexadecimal string of the alleged signature, prefixed with `0x` + /// - Returns: a `Bool` indicating if the pair message-signature is verified (whether or not the signature came from the address in the message) + /// - Throws: any errors thrown from `SiweMessage.init(_:)` and `SiweVerifier.verify(message:against)` + public final func verify(_ message: String, against signature: String) async throws -> Bool { + return try await verify(message: SiweMessage(message), against: signature) + } + + /// Verifies if a given `SiweMessage` was signed by the address in the message. + /// - Parameters: + /// - message: the message to be verified + /// - signature: the hexadecimal string of the alleged signature, prefixed with `0x` + /// - Returns: a `Bool` indicating if the pair message-signature is verified (whether or not the signature came from the address in the message) + /// - Throws: `SiweVerifier.Error` if message is not verifiable; + /// might throw `KeyUtilError` in case recovering the address that signed the message fails + open func verify(message: SiweMessage, against signature: String) async throws -> Bool { + let date = dateProvider() + + if let notBefore = message.notBefore { + if date < notBefore { + throw Error.messageIsNotActiveYet + } + } + + if let expirationTime = message.expirationTime { + if date >= expirationTime { + throw Error.messageIsExpired + } + } + + guard message.chainId == client.network?.intValue else { throw Error.differentNetwork } + + guard let messageData = "\(message)".data(using: .utf8) else { throw Error.invalidMessageData } + let prefix = "\u{19}Ethereum Signed Message:\n\(String(messageData.count))" + guard let prefixData = prefix.data(using: .ascii) else { + assertionFailure("Etherem personal message signature prefix is not valid") + throw Error.invalidMessageData + } + let messageHash = (prefixData + messageData).web3.keccak256 + + guard let signatureData = signature.web3.hexData else { throw Error.invalidSignature } + + let address = EthereumAddress(try KeyUtil.recoverPublicKey(message: messageHash, signature: signatureData)) + if address.toChecksumAddress() == message.address { + return true + } else { + let erc1271 = ERC1271(client: client) + return try await erc1271.isValidSignature( + contract: EthereumAddress(message.address), + messageHash: messageHash, + signature: signatureData + ) + } + } +} From 389d5042acdc687120a2ecb3bdf26cedf75326d1 Mon Sep 17 00:00:00 2001 From: Rodrigo Kreutz <8869678+rkreutz@users.noreply.github.com> Date: Mon, 20 Jun 2022 15:11:34 +0100 Subject: [PATCH 2/5] Fixing UTs --- web3sTests/ERC1271/ERC1271Tests.swift | 6 +++--- web3sTests/SIWE/SIWETests.swift | 2 +- web3sTests/SIWE/SiweVerifierTests.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web3sTests/ERC1271/ERC1271Tests.swift b/web3sTests/ERC1271/ERC1271Tests.swift index e24ab569..e72a6fa6 100644 --- a/web3sTests/ERC1271/ERC1271Tests.swift +++ b/web3sTests/ERC1271/ERC1271Tests.swift @@ -9,13 +9,13 @@ import XCTest @testable import web3 final class ERC1271Tests: XCTestCase { - var mockClient: EthereumClientProtocol! + var client: EthereumClientProtocol! var erc1271: ERC1271! override func setUp() { super.setUp() - self.mockClient = EthereumClient(url: URL(string: TestConfig.clientUrl)!) - self.erc1271 = ERC1271(client: self.mockClient) + self.client = EthereumHttpClient(url: URL(string: TestConfig.clientUrl)!) + self.erc1271 = ERC1271(client: self.client) } override func tearDown() { diff --git a/web3sTests/SIWE/SIWETests.swift b/web3sTests/SIWE/SIWETests.swift index 0172e769..ed00aabe 100644 --- a/web3sTests/SIWE/SIWETests.swift +++ b/web3sTests/SIWE/SIWETests.swift @@ -11,7 +11,7 @@ import XCTest final class SIWETests: XCTestCase { func testEndToEnd() async { - let verifier = SiweVerifier(client: EthereumClient(url: URL(string: TestConfig.clientUrl)!)) + let verifier = SiweVerifier(client: EthereumHttpClient(url: URL(string: TestConfig.clientUrl)!)) let account = try! EthereumAccount.init(keyStorage: TestEthereumKeyStorage(privateKey: "0x4646464646464646464646464646464646464646464646464646464646464646")) let message = try! SiweMessage( """ diff --git a/web3sTests/SIWE/SiweVerifierTests.swift b/web3sTests/SIWE/SiweVerifierTests.swift index 08143fe2..828f8c05 100644 --- a/web3sTests/SIWE/SiweVerifierTests.swift +++ b/web3sTests/SIWE/SiweVerifierTests.swift @@ -9,7 +9,7 @@ import XCTest @testable import web3 final class SiweVerifierTests: XCTestCase { - let client: EthereumClient = EthereumClient(url: URL(string: TestConfig.clientUrl)!) + let client: EthereumHttpClient = EthereumHttpClient(url: URL(string: TestConfig.clientUrl)!) func testNetworkVerification() async { let verifier = SiweVerifier(client: client, dateProvider: { Date(timeIntervalSince1970: 1_655_110_800.0) }) From add2b9f4905aa68e635e7959732e152844b6d2a9 Mon Sep 17 00:00:00 2001 From: Rodrigo Kreutz <8869678+rkreutz@users.noreply.github.com> Date: Tue, 21 Jun 2022 18:27:17 +0100 Subject: [PATCH 3/5] Adding Websocket tests --- web3sTests/ERC1271/ERC1271Tests.swift | 16 ++++++++++++++-- web3sTests/SIWE/SIWETests.swift | 24 ++++++++++++++++++++++-- web3sTests/SIWE/SiweVerifierTests.swift | 22 ++++++++++++++++++++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/web3sTests/ERC1271/ERC1271Tests.swift b/web3sTests/ERC1271/ERC1271Tests.swift index e72a6fa6..0629ab39 100644 --- a/web3sTests/ERC1271/ERC1271Tests.swift +++ b/web3sTests/ERC1271/ERC1271Tests.swift @@ -8,13 +8,15 @@ import XCTest @testable import web3 -final class ERC1271Tests: XCTestCase { +class ERC1271Tests: XCTestCase { var client: EthereumClientProtocol! var erc1271: ERC1271! override func setUp() { super.setUp() - self.client = EthereumHttpClient(url: URL(string: TestConfig.clientUrl)!) + if self.client == nil { + self.client = EthereumHttpClient(url: URL(string: TestConfig.clientUrl)!) + } self.erc1271 = ERC1271(client: self.client) } @@ -100,3 +102,13 @@ final class ERC1271Tests: XCTestCase { } } } + +final class ERC1271WSSTests: ERC1271Tests { + + override func setUp() { + if self.client == nil { + self.client = EthereumWebSocketClient(url: URL(string: TestConfig.wssUrl)!) + } + super.setUp() + } +} diff --git a/web3sTests/SIWE/SIWETests.swift b/web3sTests/SIWE/SIWETests.swift index ed00aabe..bd5aa439 100644 --- a/web3sTests/SIWE/SIWETests.swift +++ b/web3sTests/SIWE/SIWETests.swift @@ -8,10 +8,20 @@ import XCTest @testable import web3 -final class SIWETests: XCTestCase { +class SIWETests: XCTestCase { + + var client: EthereumClientProtocol! + var verifier: SiweVerifier! + + override func setUp() { + super.setUp() + if self.client == nil { + self.client = EthereumHttpClient(url: URL(string: TestConfig.clientUrl)!) + } + self.verifier = SiweVerifier(client: self.client) + } func testEndToEnd() async { - let verifier = SiweVerifier(client: EthereumHttpClient(url: URL(string: TestConfig.clientUrl)!)) let account = try! EthereumAccount.init(keyStorage: TestEthereumKeyStorage(privateKey: "0x4646464646464646464646464646464646464646464646464646464646464646")) let message = try! SiweMessage( """ @@ -45,3 +55,13 @@ final class SIWETests: XCTestCase { } } } + +final class SIWEWSSTests: SIWETests { + + override func setUp() { + if self.client == nil { + self.client = EthereumWebSocketClient(url: URL(string: TestConfig.wssUrl)!) + } + super.setUp() + } +} diff --git a/web3sTests/SIWE/SiweVerifierTests.swift b/web3sTests/SIWE/SiweVerifierTests.swift index 828f8c05..4c84fadd 100644 --- a/web3sTests/SIWE/SiweVerifierTests.swift +++ b/web3sTests/SIWE/SiweVerifierTests.swift @@ -8,8 +8,16 @@ import XCTest @testable import web3 -final class SiweVerifierTests: XCTestCase { - let client: EthereumHttpClient = EthereumHttpClient(url: URL(string: TestConfig.clientUrl)!) +class SiweVerifierTests: XCTestCase { + + var client: EthereumClientProtocol! + + override func setUp() { + super.setUp() + if self.client == nil { + self.client = EthereumHttpClient(url: URL(string: TestConfig.clientUrl)!) + } + } func testNetworkVerification() async { let verifier = SiweVerifier(client: client, dateProvider: { Date(timeIntervalSince1970: 1_655_110_800.0) }) @@ -215,3 +223,13 @@ final class SiweVerifierTests: XCTestCase { } } } + +final class SiweVerifierWSSTests: SiweVerifierTests { + + override func setUp() { + if self.client == nil { + self.client = EthereumWebSocketClient(url: URL(string: TestConfig.wssUrl)!) + } + super.setUp() + } +} From ae0987f08b1ac5fc7529393b299e72ace84eafc6 Mon Sep 17 00:00:00 2001 From: Rodrigo Kreutz <8869678+rkreutz@users.noreply.github.com> Date: Fri, 24 Jun 2022 23:21:32 +0100 Subject: [PATCH 4/5] Renaming web socket tests --- web3sTests/ERC1271/ERC1271Tests.swift | 2 +- web3sTests/SIWE/SIWETests.swift | 2 +- web3sTests/SIWE/SiweVerifierTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web3sTests/ERC1271/ERC1271Tests.swift b/web3sTests/ERC1271/ERC1271Tests.swift index 0629ab39..714c902c 100644 --- a/web3sTests/ERC1271/ERC1271Tests.swift +++ b/web3sTests/ERC1271/ERC1271Tests.swift @@ -103,7 +103,7 @@ class ERC1271Tests: XCTestCase { } } -final class ERC1271WSSTests: ERC1271Tests { +final class ERC1271WebSocketTests: ERC1271Tests { override func setUp() { if self.client == nil { diff --git a/web3sTests/SIWE/SIWETests.swift b/web3sTests/SIWE/SIWETests.swift index bd5aa439..718c4201 100644 --- a/web3sTests/SIWE/SIWETests.swift +++ b/web3sTests/SIWE/SIWETests.swift @@ -56,7 +56,7 @@ class SIWETests: XCTestCase { } } -final class SIWEWSSTests: SIWETests { +final class SIWEWebSocketTests: SIWETests { override func setUp() { if self.client == nil { diff --git a/web3sTests/SIWE/SiweVerifierTests.swift b/web3sTests/SIWE/SiweVerifierTests.swift index 4c84fadd..1f30ab5d 100644 --- a/web3sTests/SIWE/SiweVerifierTests.swift +++ b/web3sTests/SIWE/SiweVerifierTests.swift @@ -224,7 +224,7 @@ class SiweVerifierTests: XCTestCase { } } -final class SiweVerifierWSSTests: SiweVerifierTests { +final class SiweVerifierWebSocketTests: SiweVerifierTests { override func setUp() { if self.client == nil { From 39e0169c91379493584212e8314f29c75b3f7caa Mon Sep 17 00:00:00 2001 From: Rodrigo Kreutz <8869678+rkreutz@users.noreply.github.com> Date: Wed, 29 Jun 2022 17:01:48 +0100 Subject: [PATCH 5/5] Code review --- web3swift/src/SIWE/SiweVerifier.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web3swift/src/SIWE/SiweVerifier.swift b/web3swift/src/SIWE/SiweVerifier.swift index f9fe242e..7f8f704a 100644 --- a/web3swift/src/SIWE/SiweVerifier.swift +++ b/web3swift/src/SIWE/SiweVerifier.swift @@ -50,7 +50,7 @@ public class SiweVerifier { /// - Returns: a `Bool` indicating if the pair message-signature is verified (whether or not the signature came from the address in the message) /// - Throws: `SiweVerifier.Error` if message is not verifiable; /// might throw `KeyUtilError` in case recovering the address that signed the message fails - open func verify(message: SiweMessage, against signature: String) async throws -> Bool { + public func verify(message: SiweMessage, against signature: String) async throws -> Bool { let date = dateProvider() if let notBefore = message.notBefore {