From 4b9b48e613967b45c444a7ee8cdd8ed61e9da25d Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Wed, 8 Feb 2023 18:10:16 -0500 Subject: [PATCH] Fanout (#19) * Start fanout implementation * Add ack * Parse fanout message * Update fanout message parsing * FanoutClient for publishing messages * Add fanout public key * Unsafe fanout signature verification * Fix signature length check * Verify fanout expiration date * Support meta keys * Add connection id * Refactor upgrade syntax * Fix value signature * Mark discardable * Stub fanout token tests * Add algo stubs * Stub signatures * Stub fanout verify * Indent * Verify ecdsa signatures * Deps * Deps * Update ECDSA api * Deps * Update demo * Add justfile * Deps * Add back unsafe verify * Use swift-crypto * Deps * Cleanup crypto * Cleanup fanout verification * Fix uppercase * Fix bytes --- Justfile | 5 + Package.resolved | 8 +- Package.swift | 4 +- Sources/Compute/Crypto.swift | 190 ++++++++++++++++++ Sources/Compute/Fanout/FanoutClient.swift | 110 ++++++++++ Sources/Compute/Fanout/FanoutMessage.swift | 121 +++++++++++ .../Fanout/IncomingRequest+Fanout.swift | 63 ++++++ .../Fanout/OutgoingResponse+Fanout.swift | 26 +++ Sources/Compute/Fastly/FastlyTypes.swift | 15 ++ Sources/Compute/Fetch/Fetch+Wasi.swift | 4 +- Sources/Compute/IncomingRequest.swift | 23 --- Sources/Compute/JWT/JWT.swift | 75 ++++--- Sources/Compute/JWT/JWTError.swift | 3 + Sources/Compute/OutgoingResponse.swift | 2 +- Sources/ComputeDemo/main.swift | 21 +- Tests/ComputeTests/JWTTests.swift | 10 + 16 files changed, 615 insertions(+), 65 deletions(-) create mode 100644 Justfile create mode 100644 Sources/Compute/Crypto.swift create mode 100644 Sources/Compute/Fanout/FanoutClient.swift create mode 100644 Sources/Compute/Fanout/FanoutMessage.swift create mode 100644 Sources/Compute/Fanout/IncomingRequest+Fanout.swift create mode 100644 Sources/Compute/Fanout/OutgoingResponse+Fanout.swift diff --git a/Justfile b/Justfile new file mode 100644 index 00000000..828f6f4c --- /dev/null +++ b/Justfile @@ -0,0 +1,5 @@ +build: + swift build -c debug --triple wasm32-unknown-wasi + +demo: build + fastly compute serve --skip-build --file ./.build/debug/ComputeDemo.wasm diff --git a/Package.resolved b/Package.resolved index 38dbdc02..d29c6e1d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { "pins" : [ { - "identity" : "crypto", + "identity" : "swift-crypto", "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-cloud/Crypto", + "location" : "https://github.com/swift-cloud/swift-crypto", "state" : { - "revision" : "defff250bbd79d81fb8ee53926360fc522d75bad", - "version" : "1.6.0" + "revision" : "dd30eb5aaeea62282ef2ab32c9f6e1976145f920", + "version" : "2.2.4" } } ], diff --git a/Package.swift b/Package.swift index db6a56cb..e9bdbad5 100644 --- a/Package.swift +++ b/Package.swift @@ -16,10 +16,10 @@ let package = Package( .library(name: "Compute", targets: ["Compute"]) ], dependencies: [ - .package(url: "https://github.com/swift-cloud/Crypto", from: "1.6.0") + .package(url: "https://github.com/swift-cloud/swift-crypto", "1.0.0" ..< "3.0.0") ], targets: [ - .target(name: "Compute", dependencies: ["ComputeRuntime", "Crypto"]), + .target(name: "Compute", dependencies: ["ComputeRuntime", .product(name: "Crypto", package: "swift-crypto")]), .target(name: "ComputeRuntime"), .executableTarget(name: "ComputeDemo", dependencies: ["Compute"]), .testTarget(name: "ComputeTests", dependencies: ["Compute"]) diff --git a/Sources/Compute/Crypto.swift b/Sources/Compute/Crypto.swift new file mode 100644 index 00000000..beeb8da6 --- /dev/null +++ b/Sources/Compute/Crypto.swift @@ -0,0 +1,190 @@ +// +// Crypto.swift +// +// +// Created by Andrew Barba on 2/8/23. +// + +import Crypto + +public enum Crypto {} + +// MARK: - Hashing + +extension Crypto { + + public static func hash(_ input: String, using hash: T.Type) -> T.Digest where T: HashFunction { + return T.hash(data: Data(input.utf8)) + } + + public static func hash(_ input: [UInt8], using hash: T.Type) -> T.Digest where T: HashFunction { + return T.hash(data: Data(input)) + } + + public static func hash(_ input: Data, using hash: T.Type) -> T.Digest where T: HashFunction { + return T.hash(data: input) + } + + public static func sha256(_ input: String) -> SHA256.Digest { + return hash(input, using: SHA256.self) + } + + public static func sha256(_ input: [UInt8]) -> SHA256.Digest { + return hash(input, using: SHA256.self) + } + + public static func sha256(_ input: Data) -> SHA256.Digest { + return hash(input, using: SHA256.self) + } + + public static func sha384(_ input: String) -> SHA384.Digest { + return hash(input, using: SHA384.self) + } + + public static func sha384(_ input: [UInt8]) -> SHA384.Digest { + return hash(input, using: SHA384.self) + } + + public static func sha384(_ input: Data) -> SHA384.Digest { + return hash(input, using: SHA384.self) + } + + public static func sha512(_ input: String) -> SHA512.Digest { + return hash(input, using: SHA512.self) + } + + public static func sha512(_ input: [UInt8]) -> SHA512.Digest { + return hash(input, using: SHA512.self) + } + + public static func sha512(_ input: Data) -> SHA512.Digest { + return hash(input, using: SHA512.self) + } +} + +// MARK: - HMAC + +extension Crypto { + public enum Auth { + public enum Hash { + case sha256 + case sha384 + case sha512 + } + + public static func code(for input: String, secret: String, using hash: Hash) -> Data { + let data = Data(input.utf8) + let key = SymmetricKey(data: Data(secret.utf8)) + switch hash { + case .sha256: + return HMAC.authenticationCode(for: data, using: key).data + case .sha384: + return HMAC.authenticationCode(for: data, using: key).data + case .sha512: + return HMAC.authenticationCode(for: data, using: key).data + } + } + + public static func verify(_ input: String, signature: Data, secret: String, using hash: Hash) -> Bool { + let computed = code(for: input, secret: secret, using: hash) + return computed.toHexString() == signature.toHexString() + } + } +} + +// MARK: - ECDSA + +extension Crypto { + public enum ECDSA { + public enum Algorithm { + case p256 + case p384 + case p521 + } + + public static func signature(for input: String, secret: String, using algorithm: Algorithm) throws -> Data { + switch algorithm { + case .p256: + let pk = try P256.Signing.PrivateKey(pemRepresentation: secret) + return try pk.signature(for: Crypto.sha256(input)).rawRepresentation + case .p384: + let pk = try P384.Signing.PrivateKey(pemRepresentation: secret) + return try pk.signature(for: Crypto.sha384(input)).rawRepresentation + case .p521: + let pk = try P521.Signing.PrivateKey(pemRepresentation: secret) + return try pk.signature(for: Crypto.sha512(input)).rawRepresentation + } + } + + public static func verify(_ input: String, signature: Data, key: String, using algorithm: Algorithm) throws -> Bool { + switch algorithm { + case .p256: + let publicKey = try P256.Signing.PublicKey(pemRepresentation: key) + let ecdsaSignature = try P256.Signing.ECDSASignature(rawRepresentation: signature) + return publicKey.isValidSignature(ecdsaSignature, for: Crypto.sha256(input)) + case .p384: + let publicKey = try P384.Signing.PublicKey(pemRepresentation: key) + let ecdsaSignature = try P384.Signing.ECDSASignature(rawRepresentation: signature) + return publicKey.isValidSignature(ecdsaSignature, for: Crypto.sha384(input)) + case .p521: + let publicKey = try P521.Signing.PublicKey(pemRepresentation: key) + let ecdsaSignature = try P521.Signing.ECDSASignature(rawRepresentation: signature) + return publicKey.isValidSignature(ecdsaSignature, for: Crypto.sha512(input)) + } + } + } +} + +// MARK: - Utils + +extension DataProtocol { + public var bytes: [UInt8] { + return .init(self) + } + + public var data: Data { + return .init(self) + } + + public func toHexString() -> String { + return reduce("") {$0 + String(format: "%02x", $1)} + } +} + +extension Digest { + public var bytes: [UInt8] { + return .init(self) + } + + public var data: Data { + return .init(self) + } + + public func toHexString() -> String { + return reduce("") {$0 + String(format: "%02x", $1)} + } +} + +extension HashedAuthenticationCode { + public var bytes: [UInt8] { + return .init(self) + } + + public var data: Data { + return .init(self) + } + + public func toHexString() -> String { + return reduce("") {$0 + String(format: "%02x", $1)} + } +} + +extension Array where Element == UInt8 { + public var data: Data { + return .init(self) + } + + public func toHexString() -> String { + return reduce("") {$0 + String(format: "%02x", $1)} + } +} diff --git a/Sources/Compute/Fanout/FanoutClient.swift b/Sources/Compute/Fanout/FanoutClient.swift new file mode 100644 index 00000000..f7360eac --- /dev/null +++ b/Sources/Compute/Fanout/FanoutClient.swift @@ -0,0 +1,110 @@ +// +// FanoutClient.swift +// +// +// Created by Andrew Barba on 2/1/23. +// + +import Foundation + +public struct FanoutClient: Sendable { + + public let service: String + + public let hostname: String + + private let token: String + + private var publishEndpoint: String { + "https://\(hostname)/service/\(service)/publish/" + } + + public init(service: String = Fastly.Environment.serviceId, token: String, hostname: String = "api.fastly.com") { + self.service = service + self.token = token + self.hostname = hostname + } + + @discardableResult + public func publish(_ message: PublishMessage) async throws -> FetchResponse { + return try await fetch(publishEndpoint, .options( + method: .post, + body: .json(message), + headers: ["Fastly-Key": token] + )) + } + + @discardableResult + public func publish(_ content: String, to channel: String) async throws -> FetchResponse { + let message = PublishMessage(items: [ + .init(channel: channel, formats: .init(wsMessage: .init(content: content))) + ]) + return try await publish(message) + } + + @discardableResult + public func publish(_ value: T, encoder: JSONEncoder = .init(), to channel: String) async throws -> FetchResponse { + let content = try encoder.encode(value) + return try await publish(content, to: channel) + } + + @discardableResult + public func publish(_ json: Any, to channel: String) async throws -> FetchResponse { + let data = try JSONSerialization.data(withJSONObject: json) + let content = String(data: data, encoding: .utf8) + return try await publish(content, to: channel) + } + + @discardableResult + public func publish(_ jsonObject: [String: Any], to channel: String) async throws -> FetchResponse { + let data = try JSONSerialization.data(withJSONObject: jsonObject) + let content = String(data: data, encoding: .utf8) + return try await publish(content, to: channel) + } + + @discardableResult + public func publish(_ jsonArray: [Any], to channel: String) async throws -> FetchResponse { + let data = try JSONSerialization.data(withJSONObject: jsonArray) + let content = String(data: data, encoding: .utf8) + return try await publish(content, to: channel) + } +} + +extension FanoutClient { + public struct PublishMessage: Codable, Sendable { + public let items: [PublishMessageItem] + + public init(items: [PublishMessageItem]) { + self.items = items + } + } + + public struct PublishMessageItem: Codable, Sendable { + public let channel: String + public let formats: PublishMessageItemFormats + + public init(channel: String, formats: PublishMessageItemFormats) { + self.channel = channel + self.formats = formats + } + } + + public struct PublishMessageItemFormats: Codable, Sendable { + enum CodingKeys: String, CodingKey { + case wsMessage = "ws-message" + } + public let wsMessage: PublishMessageItemContent + + public init(wsMessage: PublishMessageItemContent) { + self.wsMessage = wsMessage + } + } + + public struct PublishMessageItemContent: Codable, Sendable { + public let content: String + + public init(content: String) { + self.content = content + } + } +} diff --git a/Sources/Compute/Fanout/FanoutMessage.swift b/Sources/Compute/Fanout/FanoutMessage.swift new file mode 100644 index 00000000..9c758a42 --- /dev/null +++ b/Sources/Compute/Fanout/FanoutMessage.swift @@ -0,0 +1,121 @@ +// +// FanoutMessage.swift +// +// +// Created by Andrew Barba on 1/31/23. +// + +private let eol = "\r\n" + +public enum FanoutMessageError: Error, Sendable { + case invalidFormat +} + +public struct FanoutMessage: Sendable { + public enum Event: String, Sendable, Codable { + case ack = "ACK" + case open = "OPEN" + case text = "TEXT" + case ping = "PING" + case pong = "PONG" + case close = "CLOSE" + case disconnect = "DISCONNECT" + } + + public let event: Event + + public let content: String + + public init(_ event: Event, content: String = "") { + self.event = event + self.content = content + } + + public init(_ event: Event, value: T, encoder: JSONEncoder = .init()) throws { + self.event = event + self.content = try String(data: encoder.encode(value), encoding: .utf8)! + } + + public init(_ body: String) throws { + let parts = body.components(separatedBy: eol).compactMap { $0.isEmpty ? nil : $0 } + + guard + let eventText = parts[0].components(separatedBy: " ").first, + let event = Event(rawValue: eventText) + else { + throw FanoutMessageError.invalidFormat + } + + self.event = event + self.content = parts.dropFirst().first ?? "" + } + + public func encoded() -> String { + if event == .ack { + return "" + } + switch content.isEmpty { + case true: + return "\(event.rawValue)\(eol)" + case false: + let size = String(content.count, radix: 16, uppercase: true) + return "\(event.rawValue) \(size)\(eol)\(content)\(eol)" + } + } +} + +extension FanoutMessage { + + public func decode(decoder: JSONDecoder = .init()) throws -> T { + return try decoder.decode(T.self, from: data()) + } + + public func decode(_ type: T.Type, decoder: JSONDecoder = .init()) throws -> T { + return try decoder.decode(type, from: data()) + } + + public func json() throws -> Any { + return try JSONSerialization.jsonObject(with: data()) + } + + public func jsonObject() throws -> [String: Any] { + guard let json = try JSONSerialization.jsonObject(with: data()) as? [String: Any] else { + throw FanoutMessageError.invalidFormat + } + return json + } + + public func jsonArray() throws -> [Any] { + guard let json = try JSONSerialization.jsonObject(with: data()) as? [Any] else { + throw FanoutMessageError.invalidFormat + } + return json + } + + public func data() throws -> Data { + guard let data = content.data(using: .utf8) else { + throw FanoutMessageError.invalidFormat + } + return data + } +} + +extension FanoutMessage { + + public static var ack: Self { + return .init(.ack) + } + + public static var open: Self { + return .init(.open) + } + + public static func text(_ content: String) -> Self { + return .init(.text, content: content) + } + + public static func subscribe(to channel: String) -> Self { + let content = #"c:{"type": "subscribe", "channel": "\#(channel)"}"# + return .init(.text, content: content) + } +} diff --git a/Sources/Compute/Fanout/IncomingRequest+Fanout.swift b/Sources/Compute/Fanout/IncomingRequest+Fanout.swift new file mode 100644 index 00000000..ec889034 --- /dev/null +++ b/Sources/Compute/Fanout/IncomingRequest+Fanout.swift @@ -0,0 +1,63 @@ +// +// IncomingRequest+Fanout.swift +// +// +// Created by Andrew Barba on 2/1/23. +// + +import Foundation + +public enum FanoutRequestError: Error, Sendable { + case invalidSignature +} + +extension IncomingRequest { + + public enum UpgradeWebsocketDestination { + case proxy + case fanout + } + + public func isUpgradeWebsocketRequest() -> Bool { + let connection = headers[.connection, default: ""].lowercased() + let upgrade = headers[.upgrade, default: ""].lowercased() + return connection.contains("upgrade") && upgrade.contains("websocket") + } + + public func isFanoutRequest() -> Bool { + return headers[.gripSig] != nil + } + + public var connectionId: String? { + return headers[.connectionId] + } + + public func meta(_ key: String) -> String? { + return headers["Meta-\(key)".lowercased()] + } + + public func verifyFanoutRequest() throws { + guard let token = headers[.gripSig] else { + throw FanoutRequestError.invalidSignature + } + let jwt = try JWT(token: token) + try jwt.verify(key: fanoutPublicKey, issuer: "fastly") + } + + public func upgradeWebsocket(to destination: UpgradeWebsocketDestination, hostname: String = "localhost") throws { + switch destination { + case .proxy: + try request.redirectToWebsocketProxy(backend: hostname) + case .fanout: + try request.redirectToGripProxy(backend: hostname) + } + } + + public func fanoutMessage(verifySignature: Bool = true) async throws -> FanoutMessage { + if verifySignature { + try verifyFanoutRequest() + } + let text = try await body.text() + return try FanoutMessage(text) + } +} diff --git a/Sources/Compute/Fanout/OutgoingResponse+Fanout.swift b/Sources/Compute/Fanout/OutgoingResponse+Fanout.swift new file mode 100644 index 00000000..bcaf76bd --- /dev/null +++ b/Sources/Compute/Fanout/OutgoingResponse+Fanout.swift @@ -0,0 +1,26 @@ +// +// OutgoingResponse+Fanout.swift +// +// +// Created by Andrew Barba on 2/2/23. +// + +import Foundation + +extension OutgoingResponse { + + public func meta(_ key: String, _ value: String?) -> Self { + return header("Set-Meta-\(key)".lowercased(), value) + } + + public func send(fanout messages: FanoutMessage...) async throws { + try await send(fanout: messages) + } + + public func send(fanout messages: [FanoutMessage]) async throws { + headers[.contentType] = "application/websocket-events" + headers[.secWebSocketExtensions] = "grip; message-prefix=\"\"" + let data = messages.map { $0.encoded() }.joined(separator: "").data(using: .utf8) ?? .init() + try await send(data) + } +} diff --git a/Sources/Compute/Fastly/FastlyTypes.swift b/Sources/Compute/Fastly/FastlyTypes.swift index 566bf8e6..74620d85 100644 --- a/Sources/Compute/Fastly/FastlyTypes.swift +++ b/Sources/Compute/Fastly/FastlyTypes.swift @@ -140,6 +140,7 @@ public enum HTTPHeader: String, HTTPHeaderRepresentable, Codable, Sendable { case authorization = "authorization" case cacheControl = "cache-control" case connection = "connection" + case connectionId = "connection-id" case contentDisposition = "content-disposition" case contentEncoding = "content-encoding" case contentLanguage = "content-language" @@ -155,6 +156,11 @@ public enum HTTPHeader: String, HTTPHeaderRepresentable, Codable, Sendable { case fastlyCacheKey = "fastly-xqd-cache-key" case forwarded = "forwarded" case from = "from" + case gripChannel = "grip-channel" + case gripHold = "grip-hold" + case gripKeepAlive = "grip-keep-alive" + case gripSig = "grip-sig" + case gripTimeout = "grip-timeout" case host = "host" case keepAlive = "keep-alive" case lastModified = "last-modified" @@ -165,6 +171,7 @@ public enum HTTPHeader: String, HTTPHeaderRepresentable, Codable, Sendable { case referer = "referer" case refererPolicy = "referer-policy" case server = "server" + case secWebSocketExtensions = "sec-websocket-extensions" case setCookie = "set-cookie" case surrogateControl = "surrogate-control" case surrogateKey = "surrogate-key" @@ -393,3 +400,11 @@ public let maxIpLookupLength = 2048 public let maxDictionaryEntryLength = 8000 public let highWaterMark = 4096 + +public let fanoutPublicKey = + """ + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECKo5A1ebyFcnmVV8SE5On+8G81Jy + BjSvcrx4VLetWCjuDAmppTo3xM/zz763COTCgHfp/6lPdCyYjjqc+GM7sw== + -----END PUBLIC KEY----- + """ diff --git a/Sources/Compute/Fetch/Fetch+Wasi.swift b/Sources/Compute/Fetch/Fetch+Wasi.swift index 99fd6c0e..2e8c9f5e 100644 --- a/Sources/Compute/Fetch/Fetch+Wasi.swift +++ b/Sources/Compute/Fetch/Fetch+Wasi.swift @@ -5,8 +5,6 @@ // Created by Andrew Barba on 1/15/22. // -import Crypto - internal struct WasiFetcher: Sendable { static func fetch(_ request: FetchRequest) async throws -> FetchResponse { @@ -55,7 +53,7 @@ internal struct WasiFetcher: Sendable { // Check for a custom cache key if let cacheKey = request.cacheKey { - let hash = cacheKey.bytes.sha256().toHexString().uppercased() + let hash = Crypto.sha256(cacheKey).toHexString().uppercased() try httpRequest.insertHeader(HTTPHeader.fastlyCacheKey.rawValue, hash) } diff --git a/Sources/Compute/IncomingRequest.swift b/Sources/Compute/IncomingRequest.swift index cde0f938..de13d933 100644 --- a/Sources/Compute/IncomingRequest.swift +++ b/Sources/Compute/IncomingRequest.swift @@ -64,29 +64,6 @@ public struct IncomingRequest: Sendable { } } -extension IncomingRequest { - - public enum UpgradeWebsocketBehavior { - case proxy - case fanout - } - - public func isUpgradeWebsocketRequest() -> Bool { - let connection = headers[.connection, default: ""].lowercased() - let upgrade = headers[.upgrade, default: ""].lowercased() - return connection.contains("upgrade") && upgrade.contains("websocket") - } - - public func upgradeWebsocket(backend: String, behavior: UpgradeWebsocketBehavior) throws { - switch behavior { - case .proxy: - try request.redirectToWebsocketProxy(backend: backend) - case .fanout: - try request.redirectToGripProxy(backend: backend) - } - } -} - extension IncomingRequest { public func clientFingerprint() -> String? { diff --git a/Sources/Compute/JWT/JWT.swift b/Sources/Compute/JWT/JWT.swift index 40697103..da0f62fc 100644 --- a/Sources/Compute/JWT/JWT.swift +++ b/Sources/Compute/JWT/JWT.swift @@ -5,8 +5,6 @@ // Created by Andrew Barba on 11/27/22. // -import Crypto - public struct JWT: Sendable { public let token: String @@ -17,7 +15,7 @@ public struct JWT: Sendable { public let payload: [String: Sendable] - public let signature: [UInt8] + public let signature: Data public func claim(name: String) -> Claim { return .init(value: payload[name]) @@ -98,9 +96,9 @@ public struct JWT: Sendable { let input = "\(_header).\(_payload)" - let signature = try hmacSignature(input, key: secret, using: algorithm) + let signature = try hmacSignature(input, secret: secret, using: algorithm) - let _signature = try base64UrlEncode(.init(signature)) + let _signature = try base64UrlEncode(signature) self.header = header self.payload = payload @@ -160,16 +158,11 @@ extension JWT { // Build input let input = token.components(separatedBy: ".").prefix(2).joined(separator: ".") - // Compute signature based on secret - let computedSignature = try hmacSignature(input, key: key, using: algorithm) - // Ensure the signatures match - guard signature.toHexString() == computedSignature.toHexString() else { - throw JWTError.invalidSignature - } + try verifySignature(input, signature: signature, key: key, using: algorithm) // Ensure the jwt is not expired - if expiration, expired == false { + if expiration, self.expired == true { throw JWTError.expiredToken } @@ -192,23 +185,15 @@ extension JWT { case hs256 = "HS256" case hs384 = "HS384" case hs512 = "HS512" - - internal var variant: HMAC.Variant { - switch self { - case .hs256: - return .sha2(.sha256) - case .hs384: - return .sha2(.sha384) - case .hs512: - return .sha2(.sha512) - } - } + case es256 = "ES256" + case es384 = "ES384" + case es512 = "ES512" } } private func decodeJWTPart(_ value: String) throws -> [String: Any] { let bodyData = try base64UrlDecode(value) - guard let json = try JSONSerialization.jsonObject(with: .init(bodyData), options: []) as? [String: Any] else { + guard let json = try JSONSerialization.jsonObject(with: bodyData, options: []) as? [String: Any] else { throw JWTError.invalidJSON } return json @@ -219,11 +204,45 @@ private func encodeJWTPart(_ value: [String: Any]) throws -> String { return try base64UrlEncode(data) } -private func hmacSignature(_ input: String, key: String, using algorithm: JWT.Algorithm) throws -> [UInt8] { - return try HMAC(key: key.bytes, variant: algorithm.variant).authenticate(input.bytes) +private func hmacSignature(_ input: String, secret: String, using algorithm: JWT.Algorithm) throws -> Data { + switch algorithm { + case .hs256: + return Crypto.Auth.code(for: input, secret: secret, using: .sha256) + case .hs384: + return Crypto.Auth.code(for: input, secret: secret, using: .sha384) + case .hs512: + return Crypto.Auth.code(for: input, secret: secret, using: .sha512) + case .es256: + return try Crypto.ECDSA.signature(for: input, secret: secret, using: .p256) + case .es384: + return try Crypto.ECDSA.signature(for: input, secret: secret, using: .p384) + case .es512: + return try Crypto.ECDSA.signature(for: input, secret: secret, using: .p521) + } +} + +private func verifySignature(_ input: String, signature: Data, key: String, using algorithm: JWT.Algorithm) throws { + let verified: Bool + switch algorithm { + case .es256: + verified = try Crypto.ECDSA.verify(input, signature: signature, key: key, using: .p256) + case .es384: + verified = try Crypto.ECDSA.verify(input, signature: signature, key: key, using: .p384) + case .es512: + verified = try Crypto.ECDSA.verify(input, signature: signature, key: key, using: .p521) + case .hs256: + verified = Crypto.Auth.verify(input, signature: signature, secret: key, using: .sha256) + case .hs384: + verified = Crypto.Auth.verify(input, signature: signature, secret: key, using: .sha384) + case .hs512: + verified = Crypto.Auth.verify(input, signature: signature, secret: key, using: .sha512) + } + guard verified else { + throw JWTError.invalidSignature + } } -private func base64UrlDecode(_ value: String) throws -> [UInt8] { +private func base64UrlDecode(_ value: String) throws -> Data { var base64 = value .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") @@ -237,7 +256,7 @@ private func base64UrlDecode(_ value: String) throws -> [UInt8] { guard let data = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) else { throw JWTError.invalidBase64URL } - return data.bytes + return data } private func base64UrlEncode(_ value: Data) throws -> String { diff --git a/Sources/Compute/JWT/JWTError.swift b/Sources/Compute/JWT/JWTError.swift index 10869f77..18da95a8 100644 --- a/Sources/Compute/JWT/JWTError.swift +++ b/Sources/Compute/JWT/JWTError.swift @@ -7,6 +7,7 @@ public enum JWTError: Error { case invalidToken + case invalidData case invalidBase64URL case invalidJSON case invalidSignature @@ -22,6 +23,8 @@ extension JWTError: LocalizedError { switch self { case .invalidToken: return "Invalid token" + case .invalidData: + return "Invalid data" case .invalidBase64URL: return "Invalid base64 URL" case .invalidJSON: diff --git a/Sources/Compute/OutgoingResponse.swift b/Sources/Compute/OutgoingResponse.swift index aa1f7348..aeef6b15 100644 --- a/Sources/Compute/OutgoingResponse.swift +++ b/Sources/Compute/OutgoingResponse.swift @@ -19,7 +19,7 @@ public final class OutgoingResponse { didSendAndClose || didSendStream } - public private(set) var headers: Headers + public internal(set) var headers: Headers public var status: Int { get { diff --git a/Sources/ComputeDemo/main.swift b/Sources/ComputeDemo/main.swift index 783004c4..6ad8af75 100644 --- a/Sources/ComputeDemo/main.swift +++ b/Sources/ComputeDemo/main.swift @@ -1,9 +1,22 @@ import Compute +private let token = + """ + eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJleHAiOjE2NzUzNjU0MjgsImlzcyI6ImZhc3RseSJ9.QL2Pm1JnXV_vAYK7ijeD4U1CBjOTLihNMDZ-qfvjkKOTUiK1jyxGEwjZfeApijRaOtQT8fVkdPnKjF-tBiUzkA + """ + try await onIncomingRequest { req, res in - for _ in Array(0...1000) { - let jwt = try JWT(claims: ["user_id": UUID().uuidString], secret: UUID().uuidString) - try await res.write(jwt.token + "\n") + let jwt = try JWT(token: token) + let verified: Bool + do { + try jwt.verify(key: fanoutPublicKey, issuer: "fastly", expiration: false) + verified = true + } catch { + verified = false } - try await res.end() + try await res.send([ + "verified": verified, + "signature": jwt.signature.toHexString(), + "jwt": JWT(claims: ["a": "b"], secret: "hello-world").token + ]) } diff --git a/Tests/ComputeTests/JWTTests.swift b/Tests/ComputeTests/JWTTests.swift index f872a7b9..786c1c44 100644 --- a/Tests/ComputeTests/JWTTests.swift +++ b/Tests/ComputeTests/JWTTests.swift @@ -13,6 +13,11 @@ private let token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2Njk1OTE2MTEsIm5hbWUiOiJKb2huIERvZSIsInN1YiI6IjEyMzQ1Njc4OTAifQ.FUVIl48Ji1mWZa42K1OTG0x_2T0FYOXNACsmeNI2-Kc """ +private let fanoutToken = + """ + eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJleHAiOjE2NzUzNjU0MjgsImlzcyI6ImZhc3RseSJ9.QL2Pm1JnXV_vAYK7ijeD4U1CBjOTLihNMDZ-qfvjkKOTUiK1jyxGEwjZfeApijRaOtQT8fVkdPnKjF-tBiUzkA + """ + final class JWTTests: XCTestCase { func testVerifySuccess() throws { @@ -20,6 +25,11 @@ final class JWTTests: XCTestCase { try jwt.verify(key: "your-256-bit-secret", expiration: false) } + func testVerifyFanoutSuccess() throws { + let jwt = try JWT(token: fanoutToken) + try jwt.verify(key: fanoutPublicKey, expiration: false) + } + func testVerifyFailure() throws { let jwt = try JWT(token: token) try XCTAssertThrowsError(jwt.verify(key: "bogus-secret"))