Skip to content

Commit 30d8b0b

Browse files
Added topic support
Fixes #21
1 parent ecceb68 commit 30d8b0b

File tree

5 files changed

+219
-13
lines changed

5 files changed

+219
-13
lines changed

Sources/WebPush/Topic.swift

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//
2+
// Topic.swift
3+
// swift-webpush
4+
//
5+
// Created by Dimitri Bouniol on 2024-12-24.
6+
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
7+
//
8+
9+
@preconcurrency import Crypto
10+
import Foundation
11+
12+
/// Topics are used to de-duplicate and overwrite messages on push services before they are delivered to a subscriber.
13+
///
14+
/// The topic is never delivered to your service worker, though is seen in plain text by the Push Service, so this type encodes it first to prevent leaking any information about the messages you are sending or your subscribers.
15+
///
16+
/// - Important: Since topics are sent in the clear to push services, they must be securely hashed. You must use a stable random value for this, such as the subscriber's ``UserAgentKeyMaterial/authenticationSecret``. This is fine for most applications, though you may wish to use a different key if your application requires it.
17+
///
18+
/// - SeeAlso: [RFC 8030 Generic Event Delivery Using HTTP §5.4. Replacing Push Messages](https://datatracker.ietf.org/doc/html/rfc8030#section-5.4)
19+
public struct Topic: Hashable, Sendable, CustomStringConvertible {
20+
/// The topic value to use.
21+
public let topic: String
22+
23+
/// Create a new topic from encodable data and a salt.
24+
///
25+
/// - Important: Since topics are sent in the clear to push services, they must be securely hashed. You must use a stable random value for this, such as the subscriber's ``UserAgentKeyMaterial/authenticationSecret``. This is fine for most applications, though you may wish to use a different key if your application requires it.
26+
///
27+
/// - Parameters:
28+
/// - encodableTopic: The encodable data that represents a stable topic. This can be a string, identifier, or any other token that can be encoded.
29+
/// - salt: The salt that should be used when encoding the topic.
30+
public init(
31+
encodableTopic: some Encodable,
32+
salt: some DataProtocol
33+
) throws {
34+
/// First, turn the topic into a byte stream.
35+
let encoder = JSONEncoder()
36+
encoder.outputFormatting = [.sortedKeys]
37+
let encodedTopic = try encoder.encode(encodableTopic)
38+
39+
/// Next, hash the topic using the provided salt, some info, and cut to length at 24 bytes.
40+
let hashedTopic = HKDF<SHA256>.deriveKey(
41+
inputKeyMaterial: SymmetricKey(data: encodedTopic),
42+
salt: salt,
43+
info: "WebPush Topic".utf8Bytes,
44+
outputByteCount: 24
45+
)
46+
47+
/// Transform these 24 bytes into 32 Base64 URL-safe characters.
48+
self.topic = hashedTopic.base64URLEncodedString()
49+
}
50+
51+
/// Create a new random topic.
52+
///
53+
/// Create a topic with a random identifier to save it in your own data stores, and re-use it as needed.
54+
public init() {
55+
/// Generate a 24-byte topic.
56+
var topicBytes: [UInt8] = Array(repeating: 0, count: 24)
57+
for index in topicBytes.indices { topicBytes[index] = .random(in: .min ... .max) }
58+
self.topic = topicBytes.base64URLEncodedString()
59+
}
60+
61+
/// Initialize a topic with an unchecked string.
62+
///
63+
/// Prefer to use ``init(encodableTopic:salt:)`` when possible.
64+
///
65+
/// - Warning: This may be rejected by a Push Service if it is not 32 Base64 URL-safe characters, and will not be encrypted. Expect to handle a ``PushServiceError`` with a ``PushServiceError/response`` status code of `400 Bad Request` when it does.
66+
public init(unsafeTopic: String) {
67+
topic = unsafeTopic
68+
}
69+
70+
public var description: String {
71+
topic
72+
}
73+
}
74+
75+
extension Topic: Codable {
76+
public init(from decoder: any Decoder) throws {
77+
let container = try decoder.singleValueContainer()
78+
topic = try container.decode(String.self)
79+
}
80+
81+
public func encode(to encoder: any Encoder) throws {
82+
var container = encoder.singleValueContainer()
83+
try container.encode(topic)
84+
}
85+
}

Sources/WebPush/WebPushManager.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,14 @@ public actor WebPushManager: Sendable {
250250
/// - Parameters:
251251
/// - message: The message to send as raw data.
252252
/// - subscriber: The subscriber to send the push message to.
253+
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
253254
/// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
254255
/// - urgency: The urgency of the delivery of the push message.
255256
/// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata.
256257
public func send(
257258
data message: some DataProtocol,
258259
to subscriber: some SubscriberProtocol,
260+
deduplicationTopic topic: Topic? = nil,
259261
expiration: Expiration = .recommendedMaximum,
260262
urgency: Urgency = .high,
261263
logger: Logger? = nil
@@ -269,12 +271,13 @@ public actor WebPushManager: Sendable {
269271
privateKeyProvider: privateKeyProvider,
270272
data: message,
271273
subscriber: subscriber,
274+
deduplicationTopic: topic,
272275
expiration: expiration,
273276
urgency: urgency,
274277
logger: logger
275278
)
276279
case .handler(let handler):
277-
try await handler(.data(Data(message)), Subscriber(subscriber), expiration, urgency)
280+
try await handler(.data(Data(message)), Subscriber(subscriber), topic, expiration, urgency)
278281
}
279282
}
280283

@@ -285,19 +288,22 @@ public actor WebPushManager: Sendable {
285288
/// - Parameters:
286289
/// - message: The message to send as a string.
287290
/// - subscriber: The subscriber to send the push message to.
291+
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
288292
/// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
289293
/// - urgency: The urgency of the delivery of the push message.
290294
/// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata.
291295
public func send(
292296
string message: some StringProtocol,
293297
to subscriber: some SubscriberProtocol,
298+
deduplicationTopic topic: Topic? = nil,
294299
expiration: Expiration = .recommendedMaximum,
295300
urgency: Urgency = .high,
296301
logger: Logger? = nil
297302
) async throws {
298303
try await routeMessage(
299304
message: .string(String(message)),
300305
to: subscriber,
306+
deduplicationTopic: topic,
301307
expiration: expiration,
302308
urgency: urgency,
303309
logger: logger ?? backgroundActivityLogger
@@ -311,19 +317,22 @@ public actor WebPushManager: Sendable {
311317
/// - Parameters:
312318
/// - message: The message to send as JSON.
313319
/// - subscriber: The subscriber to send the push message to.
320+
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
314321
/// - expiration: The expiration of the push message, after wich delivery will no longer be attempted.
315322
/// - urgency: The urgency of the delivery of the push message.
316323
/// - logger: The logger to use for status updates. If not provided, the background activity logger will be used instead. When running in a server environment, your contextual logger should be used instead giving you full control of logging and metadata.
317324
public func send(
318325
json message: some Encodable&Sendable,
319326
to subscriber: some SubscriberProtocol,
327+
deduplicationTopic topic: Topic? = nil,
320328
expiration: Expiration = .recommendedMaximum,
321329
urgency: Urgency = .high,
322330
logger: Logger? = nil
323331
) async throws {
324332
try await routeMessage(
325333
message: .json(message),
326334
to: subscriber,
335+
deduplicationTopic: topic,
327336
expiration: expiration,
328337
urgency: urgency,
329338
logger: logger ?? backgroundActivityLogger
@@ -334,12 +343,14 @@ public actor WebPushManager: Sendable {
334343
/// - Parameters:
335344
/// - message: The message to send.
336345
/// - subscriber: The subscriber to sign the message against.
346+
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
337347
/// - expiration: The expiration of the message.
338348
/// - urgency: The urgency of the message.
339349
/// - logger: The logger to use for status updates.
340350
func routeMessage(
341351
message: _Message,
342352
to subscriber: some SubscriberProtocol,
353+
deduplicationTopic topic: Topic?,
343354
expiration: Expiration,
344355
urgency: Urgency,
345356
logger: Logger
@@ -353,6 +364,7 @@ public actor WebPushManager: Sendable {
353364
privateKeyProvider: privateKeyProvider,
354365
data: message.data,
355366
subscriber: subscriber,
367+
deduplicationTopic: topic,
356368
expiration: expiration,
357369
urgency: urgency,
358370
logger: logger
@@ -361,6 +373,7 @@ public actor WebPushManager: Sendable {
361373
try await handler(
362374
message,
363375
Subscriber(subscriber),
376+
topic,
364377
expiration,
365378
urgency
366379
)
@@ -373,6 +386,7 @@ public actor WebPushManager: Sendable {
373386
/// - applicationServerECDHPrivateKey: The private key to use for the key exchange. If nil, one will be generated.
374387
/// - message: The message to send as raw data.
375388
/// - subscriber: The subscriber to sign the message against.
389+
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
376390
/// - expiration: The expiration of the message.
377391
/// - urgency: The urgency of the message.
378392
/// - logger: The logger to use for status updates.
@@ -381,6 +395,7 @@ public actor WebPushManager: Sendable {
381395
privateKeyProvider: Executor.KeyProvider,
382396
data message: some DataProtocol,
383397
subscriber: some SubscriberProtocol,
398+
deduplicationTopic topic: Topic?,
384399
expiration: Expiration,
385400
urgency: Urgency,
386401
logger: Logger
@@ -483,6 +498,9 @@ public actor WebPushManager: Sendable {
483498
request.headers.add(name: "Content-Type", value: "application/octet-stream")
484499
request.headers.add(name: "TTL", value: "\(max(expiration, .dropIfUndeliverable).seconds)")
485500
request.headers.add(name: "Urgency", value: "\(urgency)")
501+
if let topic {
502+
request.headers.add(name: "Topic", value: "\(topic)")
503+
}
486504
request.body = .bytes(ByteBuffer(bytes: requestContent))
487505

488506
/// Send the request to the push endpoint.
@@ -778,6 +796,7 @@ extension WebPushManager {
778796
case handler(@Sendable (
779797
_ message: _Message,
780798
_ subscriber: Subscriber,
799+
_ topic: Topic?,
781800
_ expiration: Expiration,
782801
_ urgency: Urgency
783802
) async throws -> Void)

Sources/WebPushTesting/WebPushManager+Testing.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ extension WebPushManager {
3030
messageHandler: @escaping @Sendable (
3131
_ message: Message,
3232
_ subscriber: Subscriber,
33+
_ topic: Topic?,
3334
_ expiration: Expiration,
3435
_ urgency: Urgency
3536
) async throws -> Void
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//
2+
// TopicTests.swift
3+
// swift-webpush
4+
//
5+
// Created by Dimitri Bouniol on 2024-12-24.
6+
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
7+
//
8+
9+
10+
import Crypto
11+
import Foundation
12+
import Testing
13+
@testable import WebPush
14+
15+
extension Character {
16+
var isBase64URLSafe: Bool {
17+
self.isASCII && (
18+
self.isLetter
19+
|| self.isNumber
20+
|| self == "-"
21+
|| self == "_"
22+
)
23+
}
24+
}
25+
26+
@Suite struct TopicTests {
27+
@Test func topicIsValid() throws {
28+
func checkValidity(_ topic: String) {
29+
#expect(topic.count == 32)
30+
let allSafeCharacters = topic.allSatisfy(\.isBase64URLSafe)
31+
#expect(allSafeCharacters)
32+
}
33+
checkValidity(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes).topic)
34+
checkValidity(try Topic(encodableTopic: "", salt: "Salty".utf8Bytes).topic)
35+
checkValidity(try Topic(encodableTopic: "", salt: "".utf8Bytes).topic)
36+
checkValidity(try Topic(encodableTopic: ["A", "B", "C"], salt: "SecretSalt".utf8Bytes).topic)
37+
checkValidity(try Topic(encodableTopic: ["a" : "b"], salt: "SecretSalt".utf8Bytes).topic)
38+
checkValidity(try Topic(encodableTopic: UUID(), salt: "SecretSalt".utf8Bytes).topic)
39+
checkValidity(Topic().topic)
40+
41+
struct ComplexTopic: Codable {
42+
var user = "Dimitri"
43+
var app = "Jiiiii"
44+
var id = UUID()
45+
var secretNumber = 42
46+
}
47+
checkValidity(try Topic(encodableTopic: ComplexTopic(), salt: "SecretSalt".utf8Bytes).topic)
48+
49+
do {
50+
let unsafeTopic = Topic(unsafeTopic: "test")
51+
#expect(unsafeTopic.topic.count != 32)
52+
let allSafeCharacters = unsafeTopic.topic.allSatisfy(\.isBase64URLSafe)
53+
#expect(allSafeCharacters)
54+
}
55+
do {
56+
let unsafeTopic = Topic(unsafeTopic: "()")
57+
#expect(unsafeTopic.topic.count != 32)
58+
let allSafeCharacters = unsafeTopic.topic.allSatisfy(\.isBase64URLSafe)
59+
#expect(!allSafeCharacters)
60+
}
61+
}
62+
63+
@Test func topicIsTransformed() throws {
64+
#expect(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes).topic == "mwgQxrwapKl47ipX1F8Rc84rcd2ve3M-")
65+
#expect(Topic(unsafeTopic: "test").topic == "test")
66+
#expect(Topic(unsafeTopic: "A really long test (with unsafe characters to boot ふふふ!)").topic == "A really long test (with unsafe characters to boot ふふふ!)")
67+
}
68+
69+
@Test func topicIsDescribable() throws {
70+
#expect("\(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes))" == "mwgQxrwapKl47ipX1F8Rc84rcd2ve3M-")
71+
#expect("\(Topic(unsafeTopic: "test"))" == "test")
72+
#expect("\(Topic(unsafeTopic: "A really long test (with unsafe characters to boot ふふふ!)"))" == "A really long test (with unsafe characters to boot ふふふ!)")
73+
}
74+
75+
@Test func transformsDeterministically() throws {
76+
#expect(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes) == Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes))
77+
#expect(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes) != Topic(encodableTopic: "Hello", salt: "NotSalty".utf8Bytes))
78+
#expect(try Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes) != Topic(encodableTopic: "Hello, World", salt: "Salty".utf8Bytes))
79+
}
80+
81+
@Suite struct Coding {
82+
@Test func encoding() throws {
83+
#expect(String(decoding: try JSONEncoder().encode(Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes)), as: UTF8.self) == "\"mwgQxrwapKl47ipX1F8Rc84rcd2ve3M-\"")
84+
}
85+
86+
@Test func decoding() throws {
87+
#expect(try JSONDecoder().decode(Topic.self, from: Data("\"mwgQxrwapKl47ipX1F8Rc84rcd2ve3M-\"".utf8)) == Topic(encodableTopic: "Hello", salt: "Salty".utf8Bytes))
88+
89+
#expect(try JSONDecoder().decode(Topic.self, from: Data("\"test\"".utf8)) == Topic(unsafeTopic: "test"))
90+
91+
#expect(try JSONDecoder().decode(Topic.self, from: Data("\"A really long test (with unsafe characters to boot ふふふ!)\"".utf8)) == Topic(unsafeTopic: "A really long test (with unsafe characters to boot ふふふ!)"))
92+
93+
#expect(throws: DecodingError.self) {
94+
try JSONDecoder().decode(Topic.self, from: Data("{}".utf8))
95+
}
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)