Skip to content

Commit

Permalink
Added end-to-end decryption tests for messages in various forms
Browse files Browse the repository at this point in the history
  • Loading branch information
dimitribouniol committed Dec 17, 2024
1 parent 0bc3c32 commit 3ef7f24
Show file tree
Hide file tree
Showing 2 changed files with 266 additions and 2 deletions.
3 changes: 2 additions & 1 deletion Sources/WebPush/WebPushManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ public actor WebPushManager: Sendable {
let nonce = try HKDF<SHA256>.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.
Expand Down
265 changes: 264 additions & 1 deletion Tests/WebPushTests/WebPushManagerTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// WebPushTests.swift
// WebPushManagerTests.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-03.
Expand Down Expand Up @@ -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<SHA256>.deriveKey(inputKeyMaterial: inputKeyMaterial, salt: try #require(salt), info: contentEncryptionKeyInfo, outputByteCount: 16)

let nonceInfo = "Content-Encoding: nonce".utf8Bytes + [0x00]
let nonce = try HKDF<SHA256>.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()
Expand All @@ -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)
}))
Expand All @@ -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
Expand Down

0 comments on commit 3ef7f24

Please sign in to comment.