From 02f27b6f68d93cecc22a22eab66e9716cc34e8e2 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 6 Dec 2024 00:56:16 -0800 Subject: [PATCH] Added VAPID types --- Package.swift | 2 + Sources/WebPush/VAPID/VAPID.swift | 16 ++ .../WebPush/VAPID/VAPIDConfiguration.swift | 171 ++++++++++++++++++ Sources/WebPush/VAPID/VAPIDKey.swift | 74 ++++++++ 4 files changed, 263 insertions(+) create mode 100644 Sources/WebPush/VAPID/VAPID.swift create mode 100644 Sources/WebPush/VAPID/VAPIDConfiguration.swift create mode 100644 Sources/WebPush/VAPID/VAPIDKey.swift diff --git a/Package.swift b/Package.swift index d913c7a..d31d61d 100644 --- a/Package.swift +++ b/Package.swift @@ -15,11 +15,13 @@ let package = Package( .library(name: "WebPush", targets: ["WebPush"]), ], dependencies: [ + .package(url: "https://github.com/apple/swift-crypto.git", "3.10.0"..<"5.0.0"), ], targets: [ .target( name: "WebPush", dependencies: [ + .product(name: "Crypto", package: "swift-crypto"), ] ), .testTarget(name: "WebPushTests", dependencies: ["WebPush"]), diff --git a/Sources/WebPush/VAPID/VAPID.swift b/Sources/WebPush/VAPID/VAPID.swift new file mode 100644 index 0000000..0051e55 --- /dev/null +++ b/Sources/WebPush/VAPID/VAPID.swift @@ -0,0 +1,16 @@ +// +// VAPIDKey.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-03. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import Foundation + +public typealias VAPID = VoluntaryApplicationServerIdentification + +/// A set of types for Voluntary Application Server Identification, also known as VAPID. +/// +/// - SeeAlso: [RFC8292](https://datatracker.ietf.org/doc/html/rfc8292) +public enum VoluntaryApplicationServerIdentification: Sendable {} diff --git a/Sources/WebPush/VAPID/VAPIDConfiguration.swift b/Sources/WebPush/VAPID/VAPIDConfiguration.swift new file mode 100644 index 0000000..193447e --- /dev/null +++ b/Sources/WebPush/VAPID/VAPIDConfiguration.swift @@ -0,0 +1,171 @@ +// +// VAPIDConfiguration.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-04. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import Foundation + +extension VoluntaryApplicationServerIdentification { + public struct Configuration: Hashable, Codable, Sendable { + /// The VAPID key that identifies the push service to subscribers. + /// + /// This key should be shared by all instances of your push service, and should be kept secure. Rotating this key is not recommended as you'll lose access to subscribers that registered against it. + /// + /// Some implementations will choose to use different keys per subscriber. In that case, choose to provide a set of keys instead. + public var primaryKey: Key? + public var keys: Set + public var deprecatedKeys: Set? + public var contactInformation: ContactInformation + public var expirationDuration: Duration + public var validityDuration: Duration + + public init( + key: Key, + deprecatedKeys: Set? = nil, + contactInformation: ContactInformation, + expirationDuration: Duration = .hours(22), + validityDuration: Duration = .hours(20) + ) { + self.primaryKey = key + self.keys = [key] + var deprecatedKeys = deprecatedKeys ?? [] + deprecatedKeys.remove(key) + self.deprecatedKeys = deprecatedKeys.isEmpty ? nil : deprecatedKeys + self.contactInformation = contactInformation + self.expirationDuration = expirationDuration + self.validityDuration = validityDuration + } + + public init( + primaryKey: Key?, + keys: Set, + deprecatedKeys: Set? = nil, + contactInformation: ContactInformation, + expirationDuration: Duration = .hours(22), + validityDuration: Duration = .hours(20) + ) throws { + self.primaryKey = primaryKey + var keys = keys + if let primaryKey { + keys.insert(primaryKey) + } + guard !keys.isEmpty + else { throw CancellationError() } // TODO: No keys error + + self.keys = keys + var deprecatedKeys = deprecatedKeys ?? [] + deprecatedKeys.subtract(keys) + self.deprecatedKeys = deprecatedKeys.isEmpty ? nil : deprecatedKeys + self.contactInformation = contactInformation + self.expirationDuration = expirationDuration + self.validityDuration = validityDuration + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let primaryKey = try container.decodeIfPresent(Key.self, forKey: CodingKeys.primaryKey) + let keys = try container.decode(Set.self, forKey: CodingKeys.keys) + let deprecatedKeys = try container.decodeIfPresent(Set.self, forKey: CodingKeys.deprecatedKeys) + let contactInformation = try container.decode(ContactInformation.self, forKey: CodingKeys.contactInformation) + let expirationDuration = try container.decode(Duration.self, forKey: CodingKeys.expirationDuration) + let validityDuration = try container.decode(Duration.self, forKey: CodingKeys.validityDuration) + + try self.init( + primaryKey: primaryKey, + keys: keys, + deprecatedKeys: deprecatedKeys, + contactInformation: contactInformation, + expirationDuration: expirationDuration, + validityDuration: validityDuration + ) + } + } +} + +extension VAPID.Configuration { + public enum ContactInformation: Hashable, Codable, Sendable { + case url(URL) + case email(String) + + var urlString: String { + switch self { + case .url(let url): url.absoluteURL.absoluteString + case .email(let email): "mailto:\(email)" + } + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let url = try container.decode(URL.self) + + switch url.scheme?.lowercased() { + case "mailto": + let email = String(url.absoluteString.dropFirst("mailto:".count)) + if !email.isEmpty { + self = .email(email) + } else { + throw DecodingError.typeMismatch(URL.self, .init(codingPath: decoder.codingPath, debugDescription: "Found a mailto URL with no email.")) + } + case "http", "https": + self = .url(url) + default: + throw DecodingError.typeMismatch(URL.self, .init(codingPath: decoder.codingPath, debugDescription: "Expected a mailto or http(s) URL, but found neither.")) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(urlString) + } + } + + public struct Duration: Hashable, Comparable, Codable, ExpressibleByIntegerLiteral, AdditiveArithmetic, Sendable { + public let seconds: Int + + public init(seconds: Int) { + self.seconds = seconds + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.seconds < rhs.seconds + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.seconds = try container.decode(Int.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.seconds) + } + + public init(integerLiteral value: Int) { + self.seconds = value + } + + public static func - (lhs: Self, rhs: Self) -> Self { + Self(seconds: lhs.seconds - rhs.seconds) + } + + public static func + (lhs: Self, rhs: Self) -> Self { + Self(seconds: lhs.seconds + rhs.seconds) + } + + public static func seconds(_ seconds: Int) -> Self { + Self(seconds: seconds) + } + + public static func minutes(_ minutes: Int) -> Self { + Self(seconds: minutes*60) + } + + public static func hours(_ hours: Int) -> Self { + Self(seconds: hours*60*60) + } + } +} diff --git a/Sources/WebPush/VAPID/VAPIDKey.swift b/Sources/WebPush/VAPID/VAPIDKey.swift new file mode 100644 index 0000000..40e8d38 --- /dev/null +++ b/Sources/WebPush/VAPID/VAPIDKey.swift @@ -0,0 +1,74 @@ +// +// VAPIDKey.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-04. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +@preconcurrency import Crypto +import Foundation + +extension VoluntaryApplicationServerIdentification { + public struct Key: Sendable { + private var privateKey: P256.Signing.PrivateKey + + public init() { + privateKey = P256.Signing.PrivateKey(compactRepresentable: false) + } + + public init(privateKey: P256.Signing.PrivateKey) { + self.privateKey = privateKey + } + } +} + +extension VAPID.Key: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.privateKey.rawRepresentation == rhs.privateKey.rawRepresentation + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(privateKey.rawRepresentation) + } +} + +extension VAPID.Key: Codable { + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + privateKey = try P256.Signing.PrivateKey(rawRepresentation: container.decode(Data.self)) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(privateKey.rawRepresentation) + } +} + +extension VAPID.Key: Identifiable { + public struct ID: Hashable, Comparable, Codable, Sendable { + private var rawValue: String + + init(_ rawValue: String) { + self.rawValue = rawValue + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self.rawValue = try container.decode(String.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.rawValue) + } + } + + public var id: ID { + ID(privateKey.publicKey.x963Representation.base64EncodedString()) // TODO: make url-safe + } +}