diff --git a/.gitignore b/.gitignore index 47bd4c1..704f025 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc Package.resolved + +/vapid-key-generator/.build +/vapid-key-generator/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/README.md b/README.md index 7d4024f..153f732 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Please check the [releases](https://github.com/mochidev/swift-webpush/releases) dependencies: [ .package( url: "https://github.com/mochidev/swift-webPush.git", - .upToNextMinor(from: "0.1.1") + .upToNextMinor(from: "0.1.3") ), ], ... @@ -51,8 +51,125 @@ targets: [ ## Usage +### Terminology and Core Concepts + +If you are unfamiliar with the WebPush standard, we suggest you first familiarize yourself with the following core concepts: + +
+Subscriber + +A **Subscriber** represents a device that has opted in to receive push messages from your service. + +> [!IMPORTANT] +> A subscriber should not be conflated with a user — a single user may be logged in on multiple devices, while a subscriber may be shared by multiple users on a single device. It is up to you to manage this complexity and ensure user information remains secure across session boundaries by registering, unregistering, and updating the subscriber when a user logs in or out. + +
+ +
+Application Server + +The **Application Server** is a server you run to manage subscriptions and send push notifications. The actual servers that perform these roles may be different, but they must all use the same VAPID keys to function correctly. + +> [!CAUTION] +> Using a VAPID key that wasn't registered with a subscription will result in push messages failing to reach their subscribers. + +
+ +
+VAPID Key + +**VAPID**, or _Voluntary Application Server Identification_, describes a standard for letting your application server introduce itself at time of subscription registration so that the subscription returned back to you may only be used by your service, and can't be shared with other unrelated services. + +This is made possible by generating a VAPID key pair to represent your server with. At time of registration, the public key is shared with the browser, and the subscription that is returned is locked to this key. When sending a push message, the private key is used to identify your application server to the push service so that it knows who you are before forwarding messages to subscribers. + +> [!CAUTION] +> It is important to note that you should strive to use the same key for as long as possible for a given subscriber — you won't be able to send messages to existing subscribers if you ever regenerate this key, so keep it secure! + +
+ +
+Push Service + +A **Push Service** is run by browsers to coordinate delivery of messages to subscribers on your behalf. + +
+ +### Generating Keys + +Before integrating WebPush into your server, you must generate one time VAPID keys to identify your server to push services with. To help we this, we provide `vapid-key-generator`, which you can install and use as needed: +```zsh +% git clone https://github.com/mochidev/swift-webpush.git +% cd swift-webpush/vapid-key-generator +% swift package experimental-install +``` + +To uninstall the generator: +```zsh +% package experimental-uninstall vapid-key-generator +``` + +Once installed, a new configuration can be generated as needed: +``` +% ~/.swiftpm/bin/vapid-key-generator https://example.com +VAPID.Configuration: {"contactInformation":"https://example.com","expirationDuration":79200,"primaryKey":"6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=","validityDuration":72000} + + +Example Usage: + // TODO: Load this data from .env or from file system + let configurationData = Data(#" {"contactInformation":"https://example.com","expirationDuration":79200,"primaryKey":"6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=","validityDuration":72000} "#.utf8) + let vapidConfiguration = try JSONDecoder().decode(VAPID.Configuration.self, from: configurationData) +``` + +Once generated, the configuration JSON should be added to your deployment's `.env` and kept secure so it can be accessed at runtime by your application server, and _only_ by your application server. Make sure this key does not leak and is not stored alongside subscriber information. + +> [!NOTE] +> You can specify either a support URL or an email for administrators of push services to contact you with if problems are encountered, or you can generate keys only if you prefer to configure contact information at runtime: + +```zsh +% ~/.swiftpm/bin/vapid-key-generator -h +OVERVIEW: Generate VAPID Keys. + +Generates VAPID keys and configurations suitable for use on your server. Keys should generally only be generated once +and kept secure. + +USAGE: vapid-key-generator + vapid-key-generator --email + vapid-key-generator --key-only + +ARGUMENTS: + The fully-qualified HTTPS support URL administrators of push services may contact you at: + https://example.com/support + +OPTIONS: + -k, --key-only Only generate a VAPID key. + -s, --silent Output raw JSON only so this tool can be piped with others in scripts. + -p, --pretty Output JSON with spacing. Has no effect when generating keys only. + --email Parse the input as an email address. + -h, --help Show help information. +``` + +> [!IMPORTANT] +> If you only need to change the contact information, you can do so in the JSON directly — a key should _not_ be regenerated when doing this as it will invalidate all existing subscriptions. + +> [!TIP] +> If you prefer, you can also generate keys in your own code by calling `VAPID.Key()`, but remember, the key should be persisted and re-used from that point forward! + +### Setup + +TBD + +### Registering Subscribers + TBD +### Sending Messages + +TBD + +### Testing + +The `WebPushTesting` module can be used to obtain a mocked `WebPushManager` instance that allows you to capture all messages that are sent out, or throw your own errors to validate your code functions appropriately. Only import `WebPushTesting` in your testing targets. + ## Contributing Contribution is welcome! Please take a look at the issues already available, or start a new discussion to propose a new feature. Although guarantees can't be made regarding feature requests, PRs that fit within the goals of the project and that have been discussed beforehand are more than welcome! diff --git a/Sources/WebPush/VAPID/VAPIDConfiguration.swift b/Sources/WebPush/VAPID/VAPIDConfiguration.swift index 5d871ce..b07e887 100644 --- a/Sources/WebPush/VAPID/VAPIDConfiguration.swift +++ b/Sources/WebPush/VAPID/VAPIDConfiguration.swift @@ -9,19 +9,62 @@ import Foundation extension VoluntaryApplicationServerIdentification { - public struct Configuration: Hashable, Codable, Sendable { + /// A configuration object specifying the contact information along with the keys that your application server identifies itself with. + /// + /// The ``primaryKey``, when priovided, will always be used for new subscriptions when ``WebPushManager/nextVAPIDKeyID`` is called. If omitted, one of the keys in ``keys`` will be randomely chosen instead. + /// + /// ``deprecatedKeys`` that you must stull support for older subscribers, but don't wish to use when registering new subscribers, may also be specified. + /// + /// To reduce implementation complexity, it is recommended to only use a single ``primaryKey``, though this key should be stored with subscribers as ``Subscriber`` encourages you to do so that you can deprecate it in the future should it ever leak. + /// + /// ## Codable + /// + /// VAPID configurations should ideally be generated a single time and shared across all instances of your application server, across runs. To facilitate this, you can encode and decode a configuration to load it at runtime rather than instanciate a new one every time: + /// ```swift + /// // TODO: Load this data from .env or from file system + /// let configurationData = Data(#" {"contactInformation":"https://example.com","expirationDuration":79200,"primaryKey":"6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=","validityDuration":72000} "#.utf8) + /// let vapidConfiguration = try JSONDecoder().decode(VAPID.Configuration.self, from: configurationData) + /// ``` + /// + /// - SeeAlso: [Generating Keys](https://github.com/mochidev/swift-webpush?tab=readme-ov-file#generating-keys): Keys can also be generated by our `vapid-key-generator` helper tool. + public struct Configuration: Hashable, 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. + /// If not provided, a key from ``keys`` will be used instead. + /// - SeeAlso: ``VoluntaryApplicationServerIdentification/Configuration`` public private(set) var primaryKey: Key? + + /// The set of valid keys to choose from when identifying the applications erver to new registrations. public private(set) var keys: Set + + /// The set of deprecated keys to continue to support when signing push messages, but shouldn't be used for new registrations. + /// + /// This set can be interogated via ``WebPushManager/`` to determine if a subscriber should be re-registered against a new key or not: + /// ```swift + /// webPushManager.keyStatus(for: subscriber.vapidKeyID) == .deprecated + /// ``` public private(set) var deprecatedKeys: Set? + + /// The contact information an administrator of a push service may use to contact you in the case of an issue. public var contactInformation: ContactInformation + + /// The number of seconds before a cached authentication header signed by this configuration fully expires. + /// + /// This value must be 24 hours or less, and it conservatively set to 22 hours by default to account for clock drift between your applications erver and push services. public var expirationDuration: Duration + + /// The number of seconds before a cached authentication header signed by this configuration is renewed. + /// + /// This valus must be less than ``expirationDuration``, and is set to 20 hours by default as an adequate compromise between re-usability and key over-use. public var validityDuration: Duration + /// Initialize a configuration with a single primary key. + /// - Parameters: + /// - key: The primary key to use when introducing your application server during registration. + /// - deprecatedKeys: Suppoted, but deprecated, keys to use during push delivery if a subscriber requires them. + /// - contactInformation: The contact information an administrator of a push service may use to contact you in the case of an issue. + /// - expirationDuration: The number of seconds before a cached authentication header signed by this configuration fully expires. + /// - validityDuration: The number of seconds before a cached authentication header signed by this configuration is renewed. public init( key: Key, deprecatedKeys: Set? = nil, @@ -39,6 +82,16 @@ extension VoluntaryApplicationServerIdentification { self.validityDuration = validityDuration } + /// Initialize a configuration with a multiple VAPID keys. + /// + /// Use this initializer _only_ if you wish to implement more complicated key rotation if you believe keys may be leaked at a higher rate than usual. In all other cases, it is highly recommended to use ``init(key:deprecatedKeys:contactInformation:expirationDuration:validityDuration:)`` instead to supply a singly primary key and keep it secure. + /// - Parameters: + /// - primaryKey: The optional primary key to use when introducing your application server during registration. + /// - keys: The set of valid keys to choose from when identifying the applications erver to new registrations. + /// - deprecatedKeys: Suppoted, but deprecated, keys to use during push delivery if a subscriber requires them. + /// - contactInformation: The contact information an administrator of a push service may use to contact you in the case of an issue. + /// - expirationDuration: The number of seconds before a cached authentication header signed by this configuration fully expires. + /// - validityDuration: The number of seconds before a cached authentication header signed by this configuration is renewed. public init( primaryKey: Key?, keys: Set, @@ -64,27 +117,14 @@ extension VoluntaryApplicationServerIdentification { 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 - ) - } - - mutating func updateKeys( + /// Update the keys that this configuration represents. + /// + /// At least one non-deprecated key must be specified, whether it is a primary key or specified in the list of keys, or this method will throw. + /// - Parameters: + /// - primaryKey: The primary key to use when registering a new subscriber. + /// - keys: A list of valid, non deprecated keys to cycle through if a primary key is not specified. + /// - deprecatedKeys: A list of deprecated keys to use for signing if a subscriber requires it, but won't be used for new registrations. + public mutating func updateKeys( primaryKey: Key?, keys: Set, deprecatedKeys: Set? = nil @@ -105,6 +145,57 @@ extension VoluntaryApplicationServerIdentification { } } +extension VAPID.Configuration: Codable { + public enum CodingKeys: CodingKey { + case primaryKey + case keys + case deprecatedKeys + case contactInformation + case expirationDuration + case validityDuration + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let primaryKey = try container.decodeIfPresent(VAPID.Key.self, forKey: CodingKeys.primaryKey) + let keys = try container.decodeIfPresent(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 + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + /// Remove the primary key from the list so it's not listed twice + var keys: Set? = self.keys + if let primaryKey { + keys?.remove(primaryKey) + } + if keys?.isEmpty == true { + keys = nil + } + + try container.encodeIfPresent(primaryKey, forKey: .primaryKey) + try container.encodeIfPresent(keys, forKey: .keys) + try container.encodeIfPresent(deprecatedKeys, forKey: .deprecatedKeys) + try container.encode(contactInformation, forKey: .contactInformation) + try container.encode(expirationDuration, forKey: .expirationDuration) + try container.encode(validityDuration, forKey: .validityDuration) + } +} + extension VAPID.Configuration { /// The contact information for the push service. /// @@ -150,6 +241,24 @@ extension VAPID.Configuration { } } + /// The satus of a key as it relates to a configuration. + /// + /// - SeeAlso: ``WebPushManager/keyStatus(for:)`` + public enum KeyStatus: Sendable, Hashable { + /// The key is valid and should continue to be used. + case valid + + /// The key had been deprecated. + /// + /// The user should be encouraged to re-register using a new key. + case deprecated + + /// The key is unknown to the configuration. + /// + /// The configuration should be investigated as all keys should be accounted for. + case unknown + } + public struct Duration: Hashable, Comparable, Codable, ExpressibleByIntegerLiteral, AdditiveArithmetic, Sendable { public let seconds: Int diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index 51accce..acf892d 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -175,6 +175,18 @@ public actor WebPushManager: Sendable { vapidConfiguration.primaryKey?.id ?? vapidConfiguration.keys.randomElement()!.id } + /// Check the status of a key against the current configuration. + public nonisolated func keyStatus(for keyID: VAPID.Key.ID) -> VAPID.Configuration.KeyStatus { + guard let key = vapidKeyLookup[keyID] + else { return .unknown } + + if vapidConfiguration.deprecatedKeys?.contains(key) == true { + return .deprecated + } + + return .valid + } + /// Send a push message as raw data. /// /// The service worker you registered is expected to know how to decode the data you send. diff --git a/vapid-key-generator/Package.swift b/vapid-key-generator/Package.swift new file mode 100644 index 0000000..6dd521a --- /dev/null +++ b/vapid-key-generator/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let tool = Package( + name: "vapid-key-generator", + platforms: [ + .macOS(.v13), + .iOS(.v15), + .tvOS(.v15), + .watchOS(.v8), + ], + products: [ + .executable(name: "vapid-key-generator", targets: ["VAPIDKeyGenerator"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + .package(path: "../"), + ], + targets: [ + .executableTarget( + name: "VAPIDKeyGenerator", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "WebPush", package: "swift-webpush"), + ] + ), + ] +) diff --git a/vapid-key-generator/Sources/VAPIDKeyGenerator.swift b/vapid-key-generator/Sources/VAPIDKeyGenerator.swift new file mode 100644 index 0000000..0b99229 --- /dev/null +++ b/vapid-key-generator/Sources/VAPIDKeyGenerator.swift @@ -0,0 +1,119 @@ +// +// VAPIDKeyGenerator.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-14. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import ArgumentParser +import Foundation +import WebPush + +@main +struct MyCoolerTool: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate VAPID Keys.", + usage: """ + vapid-key-generator + vapid-key-generator --email + vapid-key-generator --key-only + """, + discussion: """ + Generates VAPID keys and configurations suitable for use on your server. Keys should generally only be generated once and kept secure. + """ + ) + + @Flag(name: [.long, .customShort("k")], help: "Only generate a VAPID key.") + var keyOnly = false + + @Flag(name: [.long, .customShort("s")], help: "Output raw JSON only so this tool can be piped with others in scripts.") + var silent = false + + @Flag(name: [.long, .customShort("p")], help: "Output JSON with spacing. Has no effect when generating keys only.") + var pretty = false + + @Option(name: [.long], help: "Parse the input as an email address.") + var email: String? + + @Argument(help: "The fully-qualified HTTPS support URL administrators of push services may contact you at: https://example.com/support") var supportURL: URL? + + mutating func run() throws { + let key = VAPID.Key() + let encoder = JSONEncoder() + if pretty { + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys, .prettyPrinted] + } else { + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + } + + if keyOnly, supportURL == nil, email == nil { + let json = String(decoding: try encoder.encode(key), as: UTF8.self) + + if silent { + print("\(json)") + } else { + print("VAPID.Key (with quotes): \(json)\n\n") + print("Example Usage:") + print(" // TODO: Load this data from .env or from file system") + print(" let keyData = Data(#\" \(json) \"#.utf8)") + print(" let vapidKey = try JSONDecoder().decode(VAPID.Key.self, from: keyData)") + } + } else if !keyOnly { + let contactInformation = if let supportURL, email == nil { + VAPID.Configuration.ContactInformation.url(supportURL) + } else if supportURL == nil, let email { + VAPID.Configuration.ContactInformation.email(email) + } else if supportURL != nil, email != nil { + throw UnknownError(reason: "Only one of an email or a support-url may be specified.") + } else { + throw UnknownError(reason: "A valid support-url must be specified.") + } + if let supportURL { + guard let scheme = supportURL.scheme?.lowercased(), scheme == "http" || scheme == "https" + else { throw UnknownError(reason: "support-url must be an HTTP or HTTPS.") } + } + + let configuration = VAPID.Configuration(key: key, contactInformation: contactInformation) + + let json = String(decoding: try encoder.encode(configuration), as: UTF8.self) + + if silent { + print("\(json)") + } else { + var exampleJSON = "" + if pretty { + print("VAPID.Configuration:\n\(json)\n\n") + exampleJSON = json + exampleJSON.replace("\n", with: "\n ") + exampleJSON = "#\"\"\"\n \(exampleJSON)\n \"\"\"#" + } else { + print("VAPID.Configuration: \(json)\n\n") + exampleJSON = "#\" \(json) \"#" + } + print("Example Usage:") + print(" // TODO: Load this data from .env or from file system") + print(" let configurationData = Data(\(exampleJSON).utf8)") + print(" let vapidConfiguration = try JSONDecoder().decode(VAPID.Configuration.self, from: configurationData)") + } + } else { + if email != nil { + throw UnknownError(reason: "An email cannot be specified if only keys are being generated.") + } else { + throw UnknownError(reason: "A support-url cannot be specified if only keys are being generated.") + } + } + } +} + +struct UnknownError: LocalizedError { + var reason: String + + var errorDescription: String? { reason } +} + +extension URL: @retroactive ExpressibleByArgument { + public init?(argument: String) { + self.init(string: argument) + } +}