7
7
//
8
8
9
9
import AsyncHTTPClient
10
+ @preconcurrency import Crypto
10
11
import Foundation
12
+ import NIOHTTP1
11
13
import Logging
12
14
import NIOCore
13
15
import ServiceLifecycle
14
16
15
17
actor WebPushManager : Sendable {
16
18
public let vapidConfiguration : VAPID . Configuration
17
19
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
+
18
26
nonisolated let logger : Logger
19
27
let httpClient : HTTPClient
20
28
@@ -29,6 +37,8 @@ actor WebPushManager: Sendable {
29
37
) {
30
38
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. " ) ;
31
39
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
+
32
42
self . vapidConfiguration = vapidConfiguration
33
43
let allKeys = vapidConfiguration. keys + Array( vapidConfiguration. deprecatedKeys ?? [ ] )
34
44
self . vapidKeyLookup = Dictionary (
@@ -56,6 +66,11 @@ actor WebPushManager: Sendable {
56
66
}
57
67
}
58
68
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.
59
74
func loadCurrentVAPIDAuthorizationHeader(
60
75
endpoint: URL ,
61
76
signingKey: VAPID . Key
@@ -140,6 +155,99 @@ actor WebPushManager: Sendable {
140
155
public nonisolated var nextVAPIDKeyID : VAPID . Key . ID {
141
156
vapidConfiguration. primaryKey? . id ?? vapidConfiguration. keys. randomElement ( ) !. id
142
157
}
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
+ }
143
251
}
144
252
145
253
extension WebPushManager : Service {
@@ -159,3 +267,42 @@ extension WebPushManager: Service {
159
267
}
160
268
}
161
269
}
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