Skip to content

Tooling #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Dec 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
119 changes: 118 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
),
],
...
Expand All @@ -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:

<details>
<summary><strong>Subscriber</strong></summary>

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.

</details>

<details>
<summary><strong>Application Server</strong></summary>

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 <strong>will</strong> result in push messages failing to reach their subscribers.

</details>

<details>
<summary><strong>VAPID Key</strong></summary>

**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!

</details>

<details>
<summary><strong>Push Service</strong></summary>

A **Push Service** is run by browsers to coordinate delivery of messages to subscribers on your behalf.

</details>

### 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 <support-url>
vapid-key-generator --email <email>
vapid-key-generator --key-only

ARGUMENTS:
<support-url> 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 <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!
Expand Down
159 changes: 134 additions & 25 deletions Sources/WebPush/VAPID/VAPIDConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Key>

/// 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<Key>?

/// 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<Key>? = nil,
Expand All @@ -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<Key>,
Expand All @@ -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<Key>.self, forKey: CodingKeys.keys)
let deprecatedKeys = try container.decodeIfPresent(Set<Key>.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<Key>,
deprecatedKeys: Set<Key>? = nil
Expand All @@ -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<VAPID.Key>.self, forKey: CodingKeys.keys) ?? []
let deprecatedKeys = try container.decodeIfPresent(Set<VAPID.Key>.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<VAPID.Key>? = 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.
///
Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions Sources/WebPush/WebPushManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions vapid-key-generator/Package.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
),
]
)
Loading
Loading