Skip to content

Commit 3ef7f24

Browse files
Added end-to-end decryption tests for messages in various forms
1 parent 0bc3c32 commit 3ef7f24

File tree

2 files changed

+266
-2
lines changed

2 files changed

+266
-2
lines changed

Sources/WebPush/WebPushManager.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,8 @@ public actor WebPushManager: Sendable {
371371
let nonce = try HKDF<SHA256>.deriveKey(inputKeyMaterial: inputKeyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
372372
.withUnsafeBytes(AES.GCM.Nonce.init(data:))
373373

374-
/// Encrypt the padded payload into a single record https://datatracker.ietf.org/doc/html/rfc8188
374+
/// Encrypt the padded payload into a single record.
375+
/// - SeeAlso: [RFC 8188 Encrypted Content-Encoding for HTTP](https://datatracker.ietf.org/doc/html/rfc8188)
375376
let encryptedRecord = try AES.GCM.seal(paddedPayload, using: contentEncryptionKey, nonce: nonce)
376377

377378
/// Attach the header with our public key and salt, along with the authentication tag.

Tests/WebPushTests/WebPushManagerTests.swift

Lines changed: 264 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// WebPushTests.swift
2+
// WebPushManagerTests.swift
33
// swift-webpush
44
//
55
// Created by Dimitri Bouniol on 2024-12-03.
@@ -45,6 +45,68 @@ struct WebPushManagerTests {
4545

4646
@Suite("Sending Messages")
4747
struct SendingMessages {
48+
func validateAuthotizationHeader(
49+
request: HTTPClientRequest,
50+
vapidConfiguration: VAPID.Configuration,
51+
origin: String
52+
) throws {
53+
let auth = try #require(request.headers["Authorization"].first)
54+
let components = auth.split(separator: ",")
55+
let tComponents = try #require(components.first).split(separator: "=")
56+
let kComponents = try #require(components.last).split(separator: "=")
57+
let t = String(try #require(tComponents.last).trimming(while: \.isWhitespace))
58+
let k = String(try #require(kComponents.last).trimming(while: \.isWhitespace))
59+
#expect(k == vapidConfiguration.primaryKey?.id.description)
60+
61+
let decodedToken = try #require(VAPID.Token(token: t, key: k))
62+
#expect(decodedToken.audience == origin)
63+
#expect(decodedToken.subject == vapidConfiguration.contactInformation)
64+
#expect(decodedToken.expiration > Int(Date().timeIntervalSince1970))
65+
}
66+
67+
func decrypt(
68+
request: HTTPClientRequest,
69+
userAgentPrivateKey: P256.KeyAgreement.PrivateKey,
70+
userAgentKeyMaterial: UserAgentKeyMaterial
71+
) async throws -> [UInt8] {
72+
var body = try #require(try await request.body?.collect(upTo: 16*1024))
73+
#expect(body.readableBytes == 4096)
74+
75+
let salt = body.readBytes(length: 16)
76+
let recordSize = body.readInteger(as: UInt32.self)
77+
#expect(try #require(recordSize) == 4010)
78+
let keyIDSize = body.readInteger(as: UInt8.self)
79+
let keyID = body.readBytes(length: Int(keyIDSize ?? 0))
80+
81+
let userAgent = userAgentKeyMaterial
82+
let applicationServerECDHPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: try #require(keyID))
83+
84+
let sharedSecret = try userAgentPrivateKey.sharedSecretFromKeyAgreement(with: applicationServerECDHPublicKey)
85+
86+
let keyInfo = "WebPush: info".utf8Bytes + [0x00] + userAgent.publicKey.x963Representation + applicationServerECDHPublicKey.x963Representation
87+
let inputKeyMaterial = sharedSecret.hkdfDerivedSymmetricKey(
88+
using: SHA256.self,
89+
salt: userAgent.authenticationSecret,
90+
sharedInfo: keyInfo,
91+
outputByteCount: 32
92+
)
93+
94+
let contentEncryptionKeyInfo = "Content-Encoding: aes128gcm".utf8Bytes + [0x00]
95+
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: inputKeyMaterial, salt: try #require(salt), info: contentEncryptionKeyInfo, outputByteCount: 16)
96+
97+
let nonceInfo = "Content-Encoding: nonce".utf8Bytes + [0x00]
98+
let nonce = try HKDF<SHA256>.deriveKey(inputKeyMaterial: inputKeyMaterial, salt: try #require(salt), info: nonceInfo, outputByteCount: 12)
99+
.withUnsafeBytes(AES.GCM.Nonce.init(data:))
100+
101+
let cypherText = body.readBytes(length: body.readableBytes - 16)
102+
let tag = body.readBytes(length: 16)
103+
let encryptedRecord = try AES.GCM.SealedBox(nonce: nonce, ciphertext: #require(cypherText), tag: #require(tag))
104+
105+
let paddedPayload = try AES.GCM.open(encryptedRecord, using: contentEncryptionKey)
106+
107+
return paddedPayload.trimmingSuffix { $0 == 0 }.dropLast()
108+
}
109+
48110
@Test func sendSuccessfulTextMessage() async throws {
49111
try await confirmation { requestWasMade in
50112
let vapidConfiguration = VAPID.Configuration.makeTesting()
@@ -63,6 +125,26 @@ struct WebPushManagerTests {
63125
vapidConfiguration: vapidConfiguration,
64126
logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
65127
executor: .httpClient(MockHTTPClient({ request in
128+
try validateAuthotizationHeader(
129+
request: request,
130+
vapidConfiguration: vapidConfiguration,
131+
origin: "https://example.com"
132+
)
133+
#expect(request.method == .POST)
134+
#expect(request.headers["Content-Encoding"] == ["aes128gcm"])
135+
#expect(request.headers["Content-Type"] == ["application/octet-stream"])
136+
#expect(request.headers["TTL"] == ["2592000"])
137+
#expect(request.headers["Urgency"] == ["high"])
138+
#expect(request.headers["Topic"] == []) // TODO: Update when topic is added
139+
140+
let message = try await decrypt(
141+
request: request,
142+
userAgentPrivateKey: subscriberPrivateKey,
143+
userAgentKeyMaterial: subscriber.userAgentKeyMaterial
144+
)
145+
146+
#expect(String(decoding: message, as: UTF8.self) == "hello")
147+
66148
requestWasMade()
67149
return HTTPClientResponse(status: .created)
68150
}))
@@ -71,6 +153,187 @@ struct WebPushManagerTests {
71153
try await manager.send(string: "hello", to: subscriber)
72154
}
73155
}
156+
157+
@Test func sendSuccessfulDataMessage() async throws {
158+
try await confirmation { requestWasMade in
159+
let vapidConfiguration = VAPID.Configuration.makeTesting()
160+
161+
let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false)
162+
var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16)
163+
for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) }
164+
165+
let subscriber = Subscriber(
166+
endpoint: URL(string: "https://example.com/subscriber")!,
167+
userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)),
168+
vapidKeyID: vapidConfiguration.primaryKey!.id
169+
)
170+
171+
let manager = WebPushManager(
172+
vapidConfiguration: vapidConfiguration,
173+
logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
174+
executor: .httpClient(MockHTTPClient({ request in
175+
try validateAuthotizationHeader(
176+
request: request,
177+
vapidConfiguration: vapidConfiguration,
178+
origin: "https://example.com"
179+
)
180+
#expect(request.method == .POST)
181+
#expect(request.headers["Content-Encoding"] == ["aes128gcm"])
182+
#expect(request.headers["Content-Type"] == ["application/octet-stream"])
183+
#expect(request.headers["TTL"] == ["2592000"])
184+
#expect(request.headers["Urgency"] == ["high"])
185+
#expect(request.headers["Topic"] == []) // TODO: Update when topic is added
186+
187+
let message = try await decrypt(
188+
request: request,
189+
userAgentPrivateKey: subscriberPrivateKey,
190+
userAgentKeyMaterial: subscriber.userAgentKeyMaterial
191+
)
192+
193+
#expect(String(decoding: message, as: UTF8.self) == "hello")
194+
195+
requestWasMade()
196+
return HTTPClientResponse(status: .created)
197+
}))
198+
)
199+
200+
try await manager.send(data: "hello".utf8Bytes, to: subscriber)
201+
}
202+
}
203+
204+
@Test func sendSuccessfulJSONMessage() async throws {
205+
try await confirmation { requestWasMade in
206+
let vapidConfiguration = VAPID.Configuration.makeTesting()
207+
208+
let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false)
209+
var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16)
210+
for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) }
211+
212+
let subscriber = Subscriber(
213+
endpoint: URL(string: "https://example.com/subscriber")!,
214+
userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)),
215+
vapidKeyID: vapidConfiguration.primaryKey!.id
216+
)
217+
218+
let manager = WebPushManager(
219+
vapidConfiguration: vapidConfiguration,
220+
logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
221+
executor: .httpClient(MockHTTPClient({ request in
222+
try validateAuthotizationHeader(
223+
request: request,
224+
vapidConfiguration: vapidConfiguration,
225+
origin: "https://example.com"
226+
)
227+
#expect(request.method == .POST)
228+
#expect(request.headers["Content-Encoding"] == ["aes128gcm"])
229+
#expect(request.headers["Content-Type"] == ["application/octet-stream"])
230+
#expect(request.headers["TTL"] == ["2592000"])
231+
#expect(request.headers["Urgency"] == ["high"])
232+
#expect(request.headers["Topic"] == []) // TODO: Update when topic is added
233+
234+
let message = try await decrypt(
235+
request: request,
236+
userAgentPrivateKey: subscriberPrivateKey,
237+
userAgentKeyMaterial: subscriber.userAgentKeyMaterial
238+
)
239+
240+
#expect(String(decoding: message, as: UTF8.self) == #"{"hello":"world"}"#)
241+
242+
requestWasMade()
243+
return HTTPClientResponse(status: .created)
244+
}))
245+
)
246+
247+
try await manager.send(json: ["hello" : "world"], to: subscriber)
248+
}
249+
}
250+
251+
@Test func sendMessageToNotFoundPushServerError() async throws {
252+
await confirmation { requestWasMade in
253+
let vapidConfiguration = VAPID.Configuration.makeTesting()
254+
255+
let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false)
256+
var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16)
257+
for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) }
258+
259+
let subscriber = Subscriber(
260+
endpoint: URL(string: "https://example.com/subscriber")!,
261+
userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)),
262+
vapidKeyID: vapidConfiguration.primaryKey!.id
263+
)
264+
265+
let manager = WebPushManager(
266+
vapidConfiguration: vapidConfiguration,
267+
logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
268+
executor: .httpClient(MockHTTPClient({ request in
269+
requestWasMade()
270+
return HTTPClientResponse(status: .notFound)
271+
}))
272+
)
273+
274+
await #expect(throws: BadSubscriberError()) {
275+
try await manager.send(string: "hello", to: subscriber)
276+
}
277+
}
278+
}
279+
280+
@Test func sendMessageToGonePushServerError() async throws {
281+
await confirmation { requestWasMade in
282+
let vapidConfiguration = VAPID.Configuration.makeTesting()
283+
284+
let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false)
285+
var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16)
286+
for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) }
287+
288+
let subscriber = Subscriber(
289+
endpoint: URL(string: "https://example.com/subscriber")!,
290+
userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)),
291+
vapidKeyID: vapidConfiguration.primaryKey!.id
292+
)
293+
294+
let manager = WebPushManager(
295+
vapidConfiguration: vapidConfiguration,
296+
logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
297+
executor: .httpClient(MockHTTPClient({ request in
298+
requestWasMade()
299+
return HTTPClientResponse(status: .gone)
300+
}))
301+
)
302+
303+
await #expect(throws: BadSubscriberError()) {
304+
try await manager.send(string: "hello", to: subscriber)
305+
}
306+
}
307+
}
308+
309+
@Test func sendMessageToUnknownPushServerError() async throws {
310+
await confirmation { requestWasMade in
311+
let vapidConfiguration = VAPID.Configuration.makeTesting()
312+
313+
let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false)
314+
var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16)
315+
for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) }
316+
317+
let subscriber = Subscriber(
318+
endpoint: URL(string: "https://example.com/subscriber")!,
319+
userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)),
320+
vapidKeyID: vapidConfiguration.primaryKey!.id
321+
)
322+
323+
let manager = WebPushManager(
324+
vapidConfiguration: vapidConfiguration,
325+
logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
326+
executor: .httpClient(MockHTTPClient({ request in
327+
requestWasMade()
328+
return HTTPClientResponse(status: .internalServerError)
329+
}))
330+
)
331+
332+
await #expect(throws: HTTPError.self) {
333+
try await manager.send(string: "hello", to: subscriber)
334+
}
335+
}
336+
}
74337
}
75338

76339
@Suite

0 commit comments

Comments
 (0)