From 3ef7f244bbf85cc0b9eabaff6ec668a673298be8 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Tue, 17 Dec 2024 02:02:18 -0800 Subject: [PATCH] Added end-to-end decryption tests for messages in various forms --- Sources/WebPush/WebPushManager.swift | 3 +- Tests/WebPushTests/WebPushManagerTests.swift | 265 ++++++++++++++++++- 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index e7da5f0..1845c92 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -371,7 +371,8 @@ public actor WebPushManager: Sendable { let nonce = try HKDF.deriveKey(inputKeyMaterial: inputKeyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12) .withUnsafeBytes(AES.GCM.Nonce.init(data:)) - /// Encrypt the padded payload into a single record https://datatracker.ietf.org/doc/html/rfc8188 + /// Encrypt the padded payload into a single record. + /// - SeeAlso: [RFC 8188 Encrypted Content-Encoding for HTTP](https://datatracker.ietf.org/doc/html/rfc8188) let encryptedRecord = try AES.GCM.seal(paddedPayload, using: contentEncryptionKey, nonce: nonce) /// Attach the header with our public key and salt, along with the authentication tag. diff --git a/Tests/WebPushTests/WebPushManagerTests.swift b/Tests/WebPushTests/WebPushManagerTests.swift index 89024cb..adc21bb 100644 --- a/Tests/WebPushTests/WebPushManagerTests.swift +++ b/Tests/WebPushTests/WebPushManagerTests.swift @@ -1,5 +1,5 @@ // -// WebPushTests.swift +// WebPushManagerTests.swift // swift-webpush // // Created by Dimitri Bouniol on 2024-12-03. @@ -45,6 +45,68 @@ struct WebPushManagerTests { @Suite("Sending Messages") struct SendingMessages { + func validateAuthotizationHeader( + request: HTTPClientRequest, + vapidConfiguration: VAPID.Configuration, + origin: String + ) throws { + let auth = try #require(request.headers["Authorization"].first) + let components = auth.split(separator: ",") + let tComponents = try #require(components.first).split(separator: "=") + let kComponents = try #require(components.last).split(separator: "=") + let t = String(try #require(tComponents.last).trimming(while: \.isWhitespace)) + let k = String(try #require(kComponents.last).trimming(while: \.isWhitespace)) + #expect(k == vapidConfiguration.primaryKey?.id.description) + + let decodedToken = try #require(VAPID.Token(token: t, key: k)) + #expect(decodedToken.audience == origin) + #expect(decodedToken.subject == vapidConfiguration.contactInformation) + #expect(decodedToken.expiration > Int(Date().timeIntervalSince1970)) + } + + func decrypt( + request: HTTPClientRequest, + userAgentPrivateKey: P256.KeyAgreement.PrivateKey, + userAgentKeyMaterial: UserAgentKeyMaterial + ) async throws -> [UInt8] { + var body = try #require(try await request.body?.collect(upTo: 16*1024)) + #expect(body.readableBytes == 4096) + + let salt = body.readBytes(length: 16) + let recordSize = body.readInteger(as: UInt32.self) + #expect(try #require(recordSize) == 4010) + let keyIDSize = body.readInteger(as: UInt8.self) + let keyID = body.readBytes(length: Int(keyIDSize ?? 0)) + + let userAgent = userAgentKeyMaterial + let applicationServerECDHPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: try #require(keyID)) + + let sharedSecret = try userAgentPrivateKey.sharedSecretFromKeyAgreement(with: applicationServerECDHPublicKey) + + let keyInfo = "WebPush: info".utf8Bytes + [0x00] + userAgent.publicKey.x963Representation + applicationServerECDHPublicKey.x963Representation + let inputKeyMaterial = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: userAgent.authenticationSecret, + sharedInfo: keyInfo, + outputByteCount: 32 + ) + + let contentEncryptionKeyInfo = "Content-Encoding: aes128gcm".utf8Bytes + [0x00] + let contentEncryptionKey = HKDF.deriveKey(inputKeyMaterial: inputKeyMaterial, salt: try #require(salt), info: contentEncryptionKeyInfo, outputByteCount: 16) + + let nonceInfo = "Content-Encoding: nonce".utf8Bytes + [0x00] + let nonce = try HKDF.deriveKey(inputKeyMaterial: inputKeyMaterial, salt: try #require(salt), info: nonceInfo, outputByteCount: 12) + .withUnsafeBytes(AES.GCM.Nonce.init(data:)) + + let cypherText = body.readBytes(length: body.readableBytes - 16) + let tag = body.readBytes(length: 16) + let encryptedRecord = try AES.GCM.SealedBox(nonce: nonce, ciphertext: #require(cypherText), tag: #require(tag)) + + let paddedPayload = try AES.GCM.open(encryptedRecord, using: contentEncryptionKey) + + return paddedPayload.trimmingSuffix { $0 == 0 }.dropLast() + } + @Test func sendSuccessfulTextMessage() async throws { try await confirmation { requestWasMade in let vapidConfiguration = VAPID.Configuration.makeTesting() @@ -63,6 +125,26 @@ struct WebPushManagerTests { vapidConfiguration: vapidConfiguration, logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), executor: .httpClient(MockHTTPClient({ request in + try validateAuthotizationHeader( + request: request, + vapidConfiguration: vapidConfiguration, + origin: "https://example.com" + ) + #expect(request.method == .POST) + #expect(request.headers["Content-Encoding"] == ["aes128gcm"]) + #expect(request.headers["Content-Type"] == ["application/octet-stream"]) + #expect(request.headers["TTL"] == ["2592000"]) + #expect(request.headers["Urgency"] == ["high"]) + #expect(request.headers["Topic"] == []) // TODO: Update when topic is added + + let message = try await decrypt( + request: request, + userAgentPrivateKey: subscriberPrivateKey, + userAgentKeyMaterial: subscriber.userAgentKeyMaterial + ) + + #expect(String(decoding: message, as: UTF8.self) == "hello") + requestWasMade() return HTTPClientResponse(status: .created) })) @@ -71,6 +153,187 @@ struct WebPushManagerTests { try await manager.send(string: "hello", to: subscriber) } } + + @Test func sendSuccessfulDataMessage() async throws { + try await confirmation { requestWasMade in + let vapidConfiguration = VAPID.Configuration.makeTesting() + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: vapidConfiguration.primaryKey!.id + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + try validateAuthotizationHeader( + request: request, + vapidConfiguration: vapidConfiguration, + origin: "https://example.com" + ) + #expect(request.method == .POST) + #expect(request.headers["Content-Encoding"] == ["aes128gcm"]) + #expect(request.headers["Content-Type"] == ["application/octet-stream"]) + #expect(request.headers["TTL"] == ["2592000"]) + #expect(request.headers["Urgency"] == ["high"]) + #expect(request.headers["Topic"] == []) // TODO: Update when topic is added + + let message = try await decrypt( + request: request, + userAgentPrivateKey: subscriberPrivateKey, + userAgentKeyMaterial: subscriber.userAgentKeyMaterial + ) + + #expect(String(decoding: message, as: UTF8.self) == "hello") + + requestWasMade() + return HTTPClientResponse(status: .created) + })) + ) + + try await manager.send(data: "hello".utf8Bytes, to: subscriber) + } + } + + @Test func sendSuccessfulJSONMessage() async throws { + try await confirmation { requestWasMade in + let vapidConfiguration = VAPID.Configuration.makeTesting() + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: vapidConfiguration.primaryKey!.id + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + try validateAuthotizationHeader( + request: request, + vapidConfiguration: vapidConfiguration, + origin: "https://example.com" + ) + #expect(request.method == .POST) + #expect(request.headers["Content-Encoding"] == ["aes128gcm"]) + #expect(request.headers["Content-Type"] == ["application/octet-stream"]) + #expect(request.headers["TTL"] == ["2592000"]) + #expect(request.headers["Urgency"] == ["high"]) + #expect(request.headers["Topic"] == []) // TODO: Update when topic is added + + let message = try await decrypt( + request: request, + userAgentPrivateKey: subscriberPrivateKey, + userAgentKeyMaterial: subscriber.userAgentKeyMaterial + ) + + #expect(String(decoding: message, as: UTF8.self) == #"{"hello":"world"}"#) + + requestWasMade() + return HTTPClientResponse(status: .created) + })) + ) + + try await manager.send(json: ["hello" : "world"], to: subscriber) + } + } + + @Test func sendMessageToNotFoundPushServerError() async throws { + await confirmation { requestWasMade in + let vapidConfiguration = VAPID.Configuration.makeTesting() + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: vapidConfiguration.primaryKey!.id + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + requestWasMade() + return HTTPClientResponse(status: .notFound) + })) + ) + + await #expect(throws: BadSubscriberError()) { + try await manager.send(string: "hello", to: subscriber) + } + } + } + + @Test func sendMessageToGonePushServerError() async throws { + await confirmation { requestWasMade in + let vapidConfiguration = VAPID.Configuration.makeTesting() + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: vapidConfiguration.primaryKey!.id + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + requestWasMade() + return HTTPClientResponse(status: .gone) + })) + ) + + await #expect(throws: BadSubscriberError()) { + try await manager.send(string: "hello", to: subscriber) + } + } + } + + @Test func sendMessageToUnknownPushServerError() async throws { + await confirmation { requestWasMade in + let vapidConfiguration = VAPID.Configuration.makeTesting() + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: vapidConfiguration.primaryKey!.id + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + logger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + requestWasMade() + return HTTPClientResponse(status: .internalServerError) + })) + ) + + await #expect(throws: HTTPError.self) { + try await manager.send(string: "hello", to: subscriber) + } + } + } } @Suite