Skip to content

Commit ff59e9d

Browse files
Added a first pass at sending messages
1 parent a634d96 commit ff59e9d

File tree

6 files changed

+195
-3
lines changed

6 files changed

+195
-3
lines changed

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ let package = Package(
3030
.product(name: "Logging", package: "swift-log"),
3131
.product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
3232
.product(name: "NIOCore", package: "swift-nio"),
33+
.product(name: "NIOHTTP1", package: "swift-nio"),
3334
]
3435
),
3536
.testTarget(name: "WebPushTests", dependencies: [
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// FixedWidthInteger+BigEndienBytes.swift
3+
// swift-webpush
4+
//
5+
// Created by Dimitri Bouniol on 2024-12-11.
6+
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
7+
//
8+
9+
extension FixedWidthInteger {
10+
/// The big endian representation of the integer.
11+
@usableFromInline
12+
var bigEndianBytes: [UInt8] {
13+
withUnsafeBytes(of: self.bigEndian) { Array($0) }
14+
}
15+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// StringProtocol+UTF8Bytes.swift
3+
// swift-webpush
4+
//
5+
// Created by Dimitri Bouniol on 2024-12-11.
6+
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
7+
//
8+
9+
extension String {
10+
/// The UTF8 byte representation of the string.
11+
@usableFromInline
12+
var utf8Bytes: [UInt8] {
13+
var string = self
14+
return string.withUTF8 { Array($0) }
15+
}
16+
}
17+
18+
extension Substring {
19+
/// The UTF8 byte representation of the string.
20+
@usableFromInline
21+
var utf8Bytes: [UInt8] {
22+
var string = self
23+
return string.withUTF8 { Array($0) }
24+
}
25+
}

Sources/WebPush/Subscriber.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public struct UserAgentKeyMaterial: Sendable {
3939
/// The public key a shared secret can be derived from for message encryption.
4040
///
4141
/// - SeeAlso: [Push API Working Draft §8.1. `PushEncryptionKeyName` enumeration — `p256dh`](https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-p256dh)
42-
public var publicKey: P256.Signing.PublicKey
42+
public var publicKey: P256.KeyAgreement.PublicKey
4343

4444
/// The authentication secret to validate our ability to send a subscriber push messages.
4545
///
@@ -52,7 +52,7 @@ public struct UserAgentKeyMaterial: Sendable {
5252
/// - publicKey: The public key a shared secret can be derived from for message encryption.
5353
/// - authenticationSecret: The authentication secret to validate our ability to send a subscriber push messages.
5454
public init(
55-
publicKey: P256.Signing.PublicKey,
55+
publicKey: P256.KeyAgreement.PublicKey,
5656
authenticationSecret: Salt
5757
) {
5858
self.publicKey = publicKey
@@ -71,7 +71,7 @@ public struct UserAgentKeyMaterial: Sendable {
7171
guard let publicKeyData = Data(base64URLEncoded: publicKey)
7272
else { throw CancellationError() } // invalid public key error (underlying error = URLDecoding error)
7373
do {
74-
self.publicKey = try P256.Signing.PublicKey(x963Representation: publicKeyData)
74+
self.publicKey = try P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
7575
} catch { throw CancellationError() } // invalid public key error (underlying error = error)
7676

7777
guard let authenticationSecretData = Data(base64URLEncoded: authenticationSecret)

Sources/WebPush/VAPID/VAPIDConfiguration.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ extension VAPID.Configuration {
194194
public static func hours(_ hours: Int) -> Self {
195195
Self(seconds: hours*60*60)
196196
}
197+
198+
public static func days(_ days: Int) -> Self {
199+
Self(seconds: days*24*60*60)
200+
}
197201
}
198202
}
199203

Sources/WebPush/WebPushManager.swift

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,22 @@
77
//
88

99
import AsyncHTTPClient
10+
@preconcurrency import Crypto
1011
import Foundation
12+
import NIOHTTP1
1113
import Logging
1214
import NIOCore
1315
import ServiceLifecycle
1416

1517
actor WebPushManager: Sendable {
1618
public let vapidConfiguration: VAPID.Configuration
1719

20+
/// The maximum encrypted payload size guaranteed by the spec.
21+
public static let maximumEncryptedPayloadSize = 4096
22+
23+
/// The maximum message size allowed.
24+
public static let maximumMessageSize = maximumEncryptedPayloadSize - 103
25+
1826
nonisolated let logger: Logger
1927
let httpClient: HTTPClient
2028

@@ -29,6 +37,8 @@ actor WebPushManager: Sendable {
2937
) {
3038
assert(vapidConfiguration.validityDuration <= vapidConfiguration.expirationDuration, "The validity duration must be earlier than the expiration duration since it represents when the VAPID Authorization token will be refreshed ahead of it expiring.");
3139
assert(vapidConfiguration.expirationDuration <= .hours(24), "The expiration duration must be less than 24 hours or else push endpoints will reject messages sent to them.");
40+
precondition(!vapidConfiguration.keys.isEmpty, "VAPID.Configuration must have keys specified.")
41+
3242
self.vapidConfiguration = vapidConfiguration
3343
let allKeys = vapidConfiguration.keys + Array(vapidConfiguration.deprecatedKeys ?? [])
3444
self.vapidKeyLookup = Dictionary(
@@ -56,6 +66,11 @@ actor WebPushManager: Sendable {
5666
}
5767
}
5868

69+
/// Load an up-to-date Authorization header for the specified endpoint and signing key combo.
70+
/// - Parameters:
71+
/// - endpoint: The endpoint we'll be contacting to send push messages for a given subscriber.
72+
/// - signingKey: The signing key to sign the authorization token with.
73+
/// - Returns: An `Authorization` header string.
5974
func loadCurrentVAPIDAuthorizationHeader(
6075
endpoint: URL,
6176
signingKey: VAPID.Key
@@ -140,6 +155,99 @@ actor WebPushManager: Sendable {
140155
public nonisolated var nextVAPIDKeyID: VAPID.Key.ID {
141156
vapidConfiguration.primaryKey?.id ?? vapidConfiguration.keys.randomElement()!.id
142157
}
158+
159+
public func send(
160+
data message: some DataProtocol,
161+
to subscriber: some SubscriberProtocol,
162+
expiration: VAPID.Configuration.Duration = .days(30),
163+
urgency: Urgency = .high
164+
) async throws {
165+
guard let signingKey = vapidKeyLookup[subscriber.vapidKeyID]
166+
else { throw CancellationError() } // throw key not found error
167+
168+
/// Prepare authorization, private keys, and payload ahead of time to bail early if they can't be created.
169+
let authorization = try loadCurrentVAPIDAuthorizationHeader(endpoint: subscriber.endpoint, signingKey: signingKey)
170+
let applicationServerECDHPrivateKey = P256.KeyAgreement.PrivateKey()
171+
172+
/// Perform key exchange between the user agent's public key and our private key, deriving a shared secret.
173+
let userAgent = subscriber.userAgentKeyMaterial
174+
guard let sharedSecret = try? applicationServerECDHPrivateKey.sharedSecretFromKeyAgreement(with: userAgent.publicKey)
175+
else { throw CancellationError() } // throw bad subscription
176+
177+
/// Generate a 16-byte salt.
178+
var salt: [UInt8] = Array(repeating: 0, count: 16)
179+
for index in salt.indices { salt[index] = .random(in: .min ... .max) }
180+
181+
if message.count > Self.maximumMessageSize {
182+
logger.warning("Push message is longer than the maximum guarantee made by the spec: \(Self.maximumMessageSize) bytes. Sending this message may fail, and its size will be leaked despite being encrypted. Please consider sending less data to keep your communications secure.", metadata: ["message": "\(message)"])
183+
}
184+
185+
/// Prepare the payload by padding it so the final message is 4KB.
186+
/// Remove 103 bytes for the theoretical plaintext maximum to achieve this:
187+
/// - 16 bytes for the auth tag,
188+
/// - 1 for the minimum padding byte (0x02)
189+
/// - 86 bytes for the contentCodingHeader:
190+
/// - 16 bytes for the salt
191+
/// - 4 bytes for the record size
192+
/// - 1 byte for the key ID size
193+
/// - 65 bytes for the X9.62/3 representation of the public key
194+
/// - 1 bye for 0x04
195+
/// - 32 bytes for x coordinate
196+
/// - 32 bytes for y coordinate
197+
let paddedPayloadSize = max(message.count, Self.maximumMessageSize) // 3993
198+
let paddedPayload = message + [0x02] + Array(repeating: 0, count: paddedPayloadSize - message.count)
199+
200+
/// Prepare the remaining coding header values:
201+
let recordSize = UInt32(paddedPayload.count + 16)
202+
let keyID = applicationServerECDHPrivateKey.publicKey.x963Representation
203+
let keyIDSize = UInt8(keyID.count)
204+
let contentCodingHeader = salt + recordSize.bigEndianBytes + keyIDSize.bigEndianBytes + keyID
205+
206+
/// Derive key material (IKM) from the shared secret, salted with the public key pairs and the user agent's authentication salt.
207+
let keyInfo = "WebPush: info".utf8Bytes + [0x00] + userAgent.publicKey.x963Representation + applicationServerECDHPrivateKey.publicKey.x963Representation
208+
let inputKeyMaterial = sharedSecret.hkdfDerivedSymmetricKey(
209+
using: SHA256.self,
210+
salt: userAgent.authenticationSecret,
211+
sharedInfo: keyInfo,
212+
outputByteCount: 32
213+
)
214+
215+
/// Derive the content encryption key (CEK) for the AES transformation from the above input key material and the local salt.
216+
let contentEncryptionKeyInfo = "Content-Encoding: aes128gcm".utf8Bytes + [0x00]
217+
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: inputKeyMaterial, salt: salt, info: contentEncryptionKeyInfo, outputByteCount: 16)
218+
219+
/// Similarly, derive a nonce using a different rotation of the same key material and salt. Note that we need to transform from a Symmetric key to a nonce
220+
let nonceInfo = "Content-Encoding: nonce".utf8Bytes + [0x00]
221+
let nonce = try HKDF<SHA256>.deriveKey(inputKeyMaterial: inputKeyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
222+
.withUnsafeBytes(AES.GCM.Nonce.init(data:))
223+
224+
/// Encrypt the padded payload into a single record https://datatracker.ietf.org/doc/html/rfc8188
225+
let encryptedRecord = try AES.GCM.seal(paddedPayload, using: contentEncryptionKey, nonce: nonce)
226+
227+
/// Attach the header with our public key and salt, along with the authentication tag.
228+
let requestContent = contentCodingHeader + encryptedRecord.ciphertext + encryptedRecord.tag
229+
230+
/// Add the VAPID authorization and corrent content encoding and type.
231+
var request = HTTPClientRequest(url: subscriber.endpoint.absoluteURL.absoluteString)
232+
request.method = .POST
233+
request.headers.add(name: "Authorization", value: authorization)
234+
request.headers.add(name: "Content-Encoding", value: "aes128gcm")
235+
request.headers.add(name: "Content-Type", value: "application/octet-stream")
236+
request.headers.add(name: "TTL", value: "\(expiration.seconds)")
237+
request.headers.add(name: "Urgency", value: "\(urgency)")
238+
request.body = .bytes(ByteBuffer(bytes: requestContent))
239+
240+
/// Send the request to the push endpoint.
241+
let response = try await httpClient.execute(request, deadline: .now(), logger: logger)
242+
243+
/// Check the response and determine if the subscription should be removed from our records, or if the notification should just be skipped.
244+
switch response.status {
245+
case .created: break
246+
case .notFound, .gone: throw CancellationError() // throw bad subscription
247+
default: throw CancellationError() //Abort(response.status, headers: response.headers, reason: response.description)
248+
}
249+
logger.trace("Sent \(message) notification to \(subscriber): \(response)")
250+
}
143251
}
144252

145253
extension WebPushManager: Service {
@@ -159,3 +267,42 @@ extension WebPushManager: Service {
159267
}
160268
}
161269
}
270+
271+
public struct Urgency: Hashable, Comparable, Sendable, CustomStringConvertible {
272+
let rawValue: String
273+
274+
public static let veryLow = Self(rawValue: "very-low")
275+
public static let low = Self(rawValue: "low")
276+
public static let normal = Self(rawValue: "normal")
277+
public static let high = Self(rawValue: "high")
278+
279+
@usableFromInline
280+
var comparableValue: Int {
281+
switch self {
282+
case .high: 4
283+
case .normal: 3
284+
case .low: 2
285+
case .veryLow: 1
286+
default: 0
287+
}
288+
}
289+
290+
@inlinable
291+
public static func < (lhs: Self, rhs: Self) -> Bool {
292+
lhs.comparableValue < rhs.comparableValue
293+
}
294+
295+
public var description: String { rawValue }
296+
}
297+
298+
extension Urgency: Codable {
299+
public init(from decoder: Decoder) throws {
300+
let container = try decoder.singleValueContainer()
301+
self.rawValue = try container.decode(String.self)
302+
}
303+
304+
public func encode(to encoder: Encoder) throws {
305+
var container = encoder.singleValueContainer()
306+
try container.encode(rawValue)
307+
}
308+
}

0 commit comments

Comments
 (0)