diff --git a/Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift b/Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift index ef615ad..fb80ae1 100644 --- a/Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift +++ b/Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift @@ -9,6 +9,7 @@ import Foundation extension DataProtocol { + @_disfavoredOverload func base64URLEncodedString() -> String { Data(self) .base64EncodedString() @@ -18,8 +19,20 @@ extension DataProtocol { } } +extension ContiguousBytes { + func base64URLEncodedString() -> String { + withUnsafeBytes { bytes in + Data(bytes) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + } +} + extension DataProtocol where Self: RangeReplaceableCollection { - init?(base64URLEncoded string: String) { + init?(base64URLEncoded string: some StringProtocol) { var base64String = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") while base64String.count % 4 != 0 { base64String = base64String.appending("=") diff --git a/Sources/WebPush/VAPID/VAPIDConfiguration.swift b/Sources/WebPush/VAPID/VAPIDConfiguration.swift index 193447e..916e38d 100644 --- a/Sources/WebPush/VAPID/VAPIDConfiguration.swift +++ b/Sources/WebPush/VAPID/VAPIDConfiguration.swift @@ -87,8 +87,16 @@ extension VoluntaryApplicationServerIdentification { } extension VAPID.Configuration { + /// The contact information for the push service. + /// + /// This allows administrators of push services to contact you should an issue arise with your application server. + /// + /// - Note: Although the specification notes that this field is optional, some push services may refuse connection from serers without contact information. + /// - SeeAlso: [RFC8292 Voluntary Application Server Identification (VAPID) for Web Push §2.1. Application Server Contact Information](https://datatracker.ietf.org/doc/html/rfc8292#section-2.1) public enum ContactInformation: Hashable, Codable, Sendable { + /// A URL-based contact method, such as a support page on your website. case url(URL) + /// An email-based contact method. case email(String) var urlString: String { diff --git a/Sources/WebPush/VAPID/VAPIDKey.swift b/Sources/WebPush/VAPID/VAPIDKey.swift index 4763c25..7061c89 100644 --- a/Sources/WebPush/VAPID/VAPIDKey.swift +++ b/Sources/WebPush/VAPID/VAPIDKey.swift @@ -46,7 +46,7 @@ extension VAPID.Key: Codable { } extension VAPID.Key: Identifiable { - public struct ID: Hashable, Comparable, Codable, Sendable { + public struct ID: Hashable, Comparable, Codable, Sendable, CustomStringConvertible { private var rawValue: String init(_ rawValue: String) { @@ -66,9 +66,19 @@ extension VAPID.Key: Identifiable { var container = encoder.singleValueContainer() try container.encode(self.rawValue) } + + public var description: String { + self.rawValue + } } public var id: ID { ID(privateKey.publicKey.x963Representation.base64URLEncodedString()) } } + +extension VAPID.Key: VAPIDKeyProtocol { + func signature(for message: some DataProtocol) throws -> P256.Signing.ECDSASignature { + try privateKey.signature(for: SHA256.hash(data: message)) + } +} diff --git a/Sources/WebPush/VAPID/VAPIDToken.swift b/Sources/WebPush/VAPID/VAPIDToken.swift new file mode 100644 index 0000000..e598dd5 --- /dev/null +++ b/Sources/WebPush/VAPID/VAPIDToken.swift @@ -0,0 +1,87 @@ +// +// VAPIDToken.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-07. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +@preconcurrency import Crypto +import Foundation + +extension VAPID { + /// An internal representation the token and authorization headers used self-identification. + /// + /// - SeeAlso: [RFC8292 Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2) + struct Token: Hashable, Codable, Sendable { + enum CodingKeys: String, CodingKey { + case audience = "aud" + case subject = "sub" + case expiration = "exp" + } + + var audience: String + var subject: VAPID.Configuration.ContactInformation + var expiration: Int + + static let jwtHeader = Array(#"{"typ":"JWT","alg":"ES256"}"#.utf8).base64URLEncodedString() + + init( + origin: String, + contactInformation: VAPID.Configuration.ContactInformation, + expiresIn: VAPID.Configuration.Duration + ) { + audience = origin + subject = contactInformation + expiration = Int(Date.now.timeIntervalSince1970) + expiresIn.seconds + } + + init?(token: String, key: String) { + let components = token.split(separator: ".") + + guard + components.count == 3, + components[0] == Self.jwtHeader, + let bodyBytes = Data(base64URLEncoded: components[1]), + let signatureBytes = Data(base64URLEncoded: components[2]), + let publicKeyBytes = Data(base64URLEncoded: key) + else { return nil } + + let message = Data("\(components[0]).\(components[1])".utf8) + let publicKey = try? P256.Signing.PublicKey(x963Representation: publicKeyBytes) + let isValid = try? publicKey?.isValidSignature(.init(rawRepresentation: signatureBytes), for: SHA256.hash(data: message)) + + guard + isValid == true, + let token = try? JSONDecoder().decode(Self.self, from: bodyBytes) + else { return nil } + + self = token + } + + func generateJWT(signedBy signingKey: some VAPIDKeyProtocol) throws -> String { + let header = Self.jwtHeader + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let body = try encoder.encode(self).base64URLEncodedString() + + var message = "\(header).\(body)" + let signature = try message.withUTF8 { try signingKey.signature(for: $0) }.base64URLEncodedString() + return "\(message).\(signature)" + } + + func generateAuthorization(signedBy signingKey: some VAPIDKeyProtocol) throws -> String { + let token = try generateJWT(signedBy: signingKey) + let key = signingKey.id + + return "vapid t=\(token), k=\(key)" + } + } +} + +protocol VAPIDKeyProtocol: Identifiable, Sendable { + associatedtype Signature: ContiguousBytes + + func signature(for message: some DataProtocol) throws -> Signature +} diff --git a/Tests/WebPushTests/VAPIDTokenTests.swift b/Tests/WebPushTests/VAPIDTokenTests.swift new file mode 100644 index 0000000..2b97df6 --- /dev/null +++ b/Tests/WebPushTests/VAPIDTokenTests.swift @@ -0,0 +1,69 @@ +// +// VAPIDTokenTests.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-07. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import Crypto +import Foundation +import Testing +@testable import WebPush + +struct MockVAPIDKey: VAPIDKeyProtocol { + var id: String + var signature: Bytes + + func signature(for message: some DataProtocol) throws -> Bytes { + signature + } +} + +@Suite struct VAPIDTokenTests { + @Test func generatesValidSignedToken() throws { + let key = VAPID.Key() + + let token = VAPID.Token( + origin: "https://push.example.net", + contactInformation: .email("push@example.com"), + expiresIn: .hours(22) + ) + + let signedJWT = try token.generateJWT(signedBy: key) + #expect(VAPID.Token(token: signedJWT, key: "\(key.id)") == token) + } + + /// Make sure we can decode the example from https://datatracker.ietf.org/doc/html/rfc8292#section-2.4, as we use the same decoding logic to self-verify our own signing proceedure. + @Test func tokenVerificationMatchesSpec() throws { + var expectedToken = VAPID.Token( + origin: "https://push.example.net", + contactInformation: .email("push@example.com"), + expiresIn: 0 + ) + expectedToken.expiration = 1453523768 + + let receivedToken = VAPID.Token( + token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA", + key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs" + ) + #expect(receivedToken == expectedToken) + } + + @Test func authorizationHeaderGeneration() throws { + var expectedToken = VAPID.Token( + origin: "https://push.example.net", + contactInformation: .email("push@example.com"), + expiresIn: 0 + ) + expectedToken.expiration = 1453523768 + + let mockKey = MockVAPIDKey( + id: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs", + signature: Data(base64URLEncoded: "i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA")! + ) + + let generatedHeader = try expectedToken.generateAuthorization(signedBy: mockKey) + #expect(generatedHeader == "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA, k=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs") + } +}