Skip to content

Commit

Permalink
Merge pull request #67 from vapor-community/feature/verify-signature
Browse files Browse the repository at this point in the history
Verify Signature
  • Loading branch information
Andrewangeta authored Mar 24, 2020
2 parents 9772e95 + c6c143a commit 6e6a0f3
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 2 deletions.
8 changes: 6 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ import PackageDescription

let package = Package(
name: "StripeKit",
platforms: [
.macOS(.v10_15)
],
products: [
.library(name: "StripeKit", targets: ["StripeKit"])
],
dependencies: [
.package(url: "https://github.com/swift-server/async-http-client.git", .exact("1.1.0")),
.package(url: "https://github.com/swift-server/async-http-client.git", from: "1.1.0"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "1.0.0")
],
targets: [
.target(name: "StripeKit", dependencies: ["AsyncHTTPClient"]),
.target(name: "StripeKit", dependencies: ["AsyncHTTPClient", "Crypto"]),
.testTarget(name: "StripeKitTests", dependencies: ["AsyncHTTPClient", "StripeKit"])
]
)
9 changes: 9 additions & 0 deletions Sources/StripeKit/Errors/StripeSignatureError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/// An error returned when verifying signatures
public enum StripeSignatureError: Error {
/// The supplied header could not be parsed in the appropiate format `"t=xxx,v1=yyy"`
case unableToParseHeader
/// No signatures were found that matched the expected signature
case noMatchingSignatureFound
/// The timestamp was outside of the tolerated time difference
case timestampNotTolerated
}
26 changes: 26 additions & 0 deletions Sources/StripeKit/Extensions/String+Hex.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation

extension String {
enum ExtendedEncoding {
case hexadecimal
}

func data(using encoding:ExtendedEncoding) -> Data? {
let hexStr = self.dropFirst(self.hasPrefix("0x") ? 2 : 0)

guard hexStr.count % 2 == 0 else { return nil }

var newData = Data(capacity: hexStr.count/2)

var indexIsEven = true
for i in hexStr.indices {
if indexIsEven {
let byteRange = i...hexStr.index(after: i)
guard let byte = UInt8(hexStr[byteRange], radix: 16) else { return nil }
newData.append(byte)
}
indexIsEven.toggle()
}
return newData
}
}
61 changes: 61 additions & 0 deletions Sources/StripeKit/StripeClient+SignatureVerification.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation
import AsyncHTTPClient
import Crypto
import NIO
import NIOFoundationCompat
import NIOHTTP1

extension StripeClient {
/// Verify a signature from Stripe
/// - Parameters:
/// - payload: The JSON payload as `Data`
/// - header: The `Stripe-Signature` HTTP-Header value
/// - secret: The secret associated with the webhook
/// - tolerance: In seconds the time difference tolerance to prevent replay attacks: Default 300 seconds
static public func verifySignature(payload: Data, header: String, secret: String, tolerance: Double = 300) throws {
let signaturePairs = header.components(separatedBy: ",")

let signatures = signaturePairs.reduce(into: [String]()) { result, component in
let kvPair = component.components(separatedBy: "=")

guard kvPair.count == 2 else { return }

if kvPair.first == "v1" { result.append(kvPair.last!) }
}

guard let timestamp = signaturePairs
.first(where: { $0.starts(with: "t=")})?
.components(separatedBy: "=")
.last
else {
throw StripeSignatureError.unableToParseHeader
}

let payloadString = String(data: payload, encoding: .utf8)!
let combinedPayload = [timestamp, payloadString].joined(separator: ".").data(using: .utf8)!

let secretKey = SymmetricKey(data: secret.data(using: .utf8)!)

let result = signatures.map { signature -> Bool in
guard let data = signature.data(using: .hexadecimal) else {
return false
}

return HMAC<SHA256>.isValidAuthenticationCode(data, authenticating: combinedPayload, using: secretKey)
}

guard result.contains(true) else {
throw StripeSignatureError.noMatchingSignatureFound
}

guard let time = Double(timestamp) else {
throw StripeSignatureError.unableToParseHeader
}

let timeDifference = Date().timeIntervalSince(Date(timeIntervalSince1970: time))

if tolerance > 0 && timeDifference > tolerance || timeDifference < 0 {
throw StripeSignatureError.timestampNotTolerated
}
}
}
54 changes: 54 additions & 0 deletions Tests/StripeKitTests/SignatureVerificationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import XCTest
@testable import StripeKit
import Crypto

class SignatureVerificationTests: XCTestCase {
var jsonData: Data = try! JSONEncoder().encode(["key": "value"])
var secret: String = "SECRET"

func testVerificationWithSingleSignature() throws {
let timestamp = String(Date().addingTimeInterval(-60).timeIntervalSince1970)
let secretData = secret.data(using: .utf8)!
let data = [timestamp, String(data: jsonData, encoding: .utf8)!].joined(separator: ".").data(using: .utf8)!
let hash = Data(HMAC<SHA256>.authenticationCode(for: data, using: SymmetricKey(data: secretData))).hexString
let header = "t=\(timestamp),v1=\(hash)"
XCTAssertNoThrow(try StripeClient.verifySignature(payload: jsonData, header: header, secret:secret))
}

func testVerificationWithMultipleSignatures() throws {
let header = "t=123,v1=7b15c7edc2183ad5be71922cc180f70b1ce4c0925c45abb6b1676ad43cb79173,v1=911b8d64f1a89c73cec478d4ace90345a6c268f5f60892060ea1af531b4fe97c"

XCTAssertNoThrow(try StripeClient.verifySignature(payload: jsonData, header: header, secret: secret, tolerance: -1))
}

func testVerificationWithInvalidHeaderThrows() throws {
XCTAssertThrowsError(try StripeClient.verifySignature(payload: jsonData, header: "a", secret: secret, tolerance: -1)) { error in
XCTAssertEqual(error as? StripeSignatureError, StripeSignatureError.unableToParseHeader)
}
}

func testVerificationWithWrongSignatureThrows() throws {
XCTAssertThrowsError(try StripeClient.verifySignature(payload: jsonData, header: "t=123,v1=abc", secret: secret, tolerance: -1)) { error in
XCTAssertEqual(error as? StripeSignatureError, StripeSignatureError.noMatchingSignatureFound)
}
}

func testVerificationWithNonToleratedTimestamp() throws {
// Subtracting 6 minutes
let timestamp = String(Date().addingTimeInterval(-360).timeIntervalSince1970)
let secretData = secret.data(using: .utf8)!
let data = [timestamp, String(data: jsonData, encoding: .utf8)!].joined(separator: ".").data(using: .utf8)!
let hash = Data(HMAC<SHA256>.authenticationCode(for: data, using: SymmetricKey(data: secretData))).hexString
let header = "t=\(timestamp),v1=\(hash)"

XCTAssertThrowsError(try StripeClient.verifySignature(payload: jsonData, header: header, secret: secret)) { error in
XCTAssertEqual(error as? StripeSignatureError, StripeSignatureError.timestampNotTolerated)
}
}
}

extension Data {
var hexString: String {
return self.reduce("", { $0 + String(format: "%02x", $1) })
}
}
14 changes: 14 additions & 0 deletions Tests/StripeKitTests/XCTestManifests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,23 @@ extension QueryEncodingTests {
]
}

extension SignatureVerificationTests {
// DO NOT MODIFY: This is autogenerated, use:
// `swift test --generate-linuxmain`
// to regenerate.
static let __allTests__SignatureVerificationTests = [
("testVerificationWithInvalidHeaderThrows", testVerificationWithInvalidHeaderThrows),
("testVerificationWithMultipleSignatures", testVerificationWithMultipleSignatures),
("testVerificationWithNonToleratedTimestamp", testVerificationWithNonToleratedTimestamp),
("testVerificationWithSingleSignature", testVerificationWithSingleSignature),
("testVerificationWithWrongSignatureThrows", testVerificationWithWrongSignatureThrows),
]
}

public func __allTests() -> [XCTestCaseEntry] {
return [
testCase(QueryEncodingTests.__allTests__QueryEncodingTests),
testCase(SignatureVerificationTests.__allTests__SignatureVerificationTests),
]
}
#endif

0 comments on commit 6e6a0f3

Please sign in to comment.