77//
88
99import AsyncHTTPClient
10+ @preconcurrency import Crypto
1011import Foundation
12+ import NIOHTTP1
1113import Logging
1214import NIOCore
1315import ServiceLifecycle
1416
1517actor 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
145253extension 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