Skip to content

Commit

Permalink
Added VAPID types
Browse files Browse the repository at this point in the history
  • Loading branch information
dimitribouniol committed Dec 6, 2024
1 parent 63db911 commit 02f27b6
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 0 deletions.
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ let package = Package(
.library(name: "WebPush", targets: ["WebPush"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-crypto.git", "3.10.0"..<"5.0.0"),
],
targets: [
.target(
name: "WebPush",
dependencies: [
.product(name: "Crypto", package: "swift-crypto"),
]
),
.testTarget(name: "WebPushTests", dependencies: ["WebPush"]),
Expand Down
16 changes: 16 additions & 0 deletions Sources/WebPush/VAPID/VAPID.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// VAPIDKey.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-03.
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
//

import Foundation

public typealias VAPID = VoluntaryApplicationServerIdentification

/// A set of types for Voluntary Application Server Identification, also known as VAPID.
///
/// - SeeAlso: [RFC8292](https://datatracker.ietf.org/doc/html/rfc8292)
public enum VoluntaryApplicationServerIdentification: Sendable {}
171 changes: 171 additions & 0 deletions Sources/WebPush/VAPID/VAPIDConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
//
// VAPIDConfiguration.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-04.
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
//

import Foundation

extension VoluntaryApplicationServerIdentification {
public struct Configuration: Hashable, Codable, Sendable {
/// The VAPID key that identifies the push service to subscribers.
///
/// This key should be shared by all instances of your push service, and should be kept secure. Rotating this key is not recommended as you'll lose access to subscribers that registered against it.
///
/// Some implementations will choose to use different keys per subscriber. In that case, choose to provide a set of keys instead.
public var primaryKey: Key?
public var keys: Set<Key>
public var deprecatedKeys: Set<Key>?
public var contactInformation: ContactInformation
public var expirationDuration: Duration
public var validityDuration: Duration

public init(
key: Key,
deprecatedKeys: Set<Key>? = nil,
contactInformation: ContactInformation,
expirationDuration: Duration = .hours(22),
validityDuration: Duration = .hours(20)
) {
self.primaryKey = key
self.keys = [key]
var deprecatedKeys = deprecatedKeys ?? []
deprecatedKeys.remove(key)
self.deprecatedKeys = deprecatedKeys.isEmpty ? nil : deprecatedKeys
self.contactInformation = contactInformation
self.expirationDuration = expirationDuration
self.validityDuration = validityDuration
}

public init(
primaryKey: Key?,
keys: Set<Key>,
deprecatedKeys: Set<Key>? = nil,
contactInformation: ContactInformation,
expirationDuration: Duration = .hours(22),
validityDuration: Duration = .hours(20)
) throws {
self.primaryKey = primaryKey
var keys = keys
if let primaryKey {
keys.insert(primaryKey)
}
guard !keys.isEmpty
else { throw CancellationError() } // TODO: No keys error

self.keys = keys
var deprecatedKeys = deprecatedKeys ?? []
deprecatedKeys.subtract(keys)
self.deprecatedKeys = deprecatedKeys.isEmpty ? nil : deprecatedKeys
self.contactInformation = contactInformation
self.expirationDuration = expirationDuration
self.validityDuration = validityDuration
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

let primaryKey = try container.decodeIfPresent(Key.self, forKey: CodingKeys.primaryKey)
let keys = try container.decode(Set<Key>.self, forKey: CodingKeys.keys)
let deprecatedKeys = try container.decodeIfPresent(Set<Key>.self, forKey: CodingKeys.deprecatedKeys)
let contactInformation = try container.decode(ContactInformation.self, forKey: CodingKeys.contactInformation)
let expirationDuration = try container.decode(Duration.self, forKey: CodingKeys.expirationDuration)
let validityDuration = try container.decode(Duration.self, forKey: CodingKeys.validityDuration)

try self.init(
primaryKey: primaryKey,
keys: keys,
deprecatedKeys: deprecatedKeys,
contactInformation: contactInformation,
expirationDuration: expirationDuration,
validityDuration: validityDuration
)
}
}
}

extension VAPID.Configuration {
public enum ContactInformation: Hashable, Codable, Sendable {
case url(URL)
case email(String)

var urlString: String {
switch self {
case .url(let url): url.absoluteURL.absoluteString
case .email(let email): "mailto:\(email)"
}
}

public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let url = try container.decode(URL.self)

switch url.scheme?.lowercased() {
case "mailto":
let email = String(url.absoluteString.dropFirst("mailto:".count))
if !email.isEmpty {
self = .email(email)
} else {
throw DecodingError.typeMismatch(URL.self, .init(codingPath: decoder.codingPath, debugDescription: "Found a mailto URL with no email."))
}
case "http", "https":
self = .url(url)
default:
throw DecodingError.typeMismatch(URL.self, .init(codingPath: decoder.codingPath, debugDescription: "Expected a mailto or http(s) URL, but found neither."))
}
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(urlString)
}
}

public struct Duration: Hashable, Comparable, Codable, ExpressibleByIntegerLiteral, AdditiveArithmetic, Sendable {
public let seconds: Int

public init(seconds: Int) {
self.seconds = seconds
}

public static func < (lhs: Self, rhs: Self) -> Bool {
lhs.seconds < rhs.seconds
}

public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.seconds = try container.decode(Int.self)
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.seconds)
}

public init(integerLiteral value: Int) {
self.seconds = value
}

public static func - (lhs: Self, rhs: Self) -> Self {
Self(seconds: lhs.seconds - rhs.seconds)
}

public static func + (lhs: Self, rhs: Self) -> Self {
Self(seconds: lhs.seconds + rhs.seconds)
}

public static func seconds(_ seconds: Int) -> Self {
Self(seconds: seconds)
}

public static func minutes(_ minutes: Int) -> Self {
Self(seconds: minutes*60)
}

public static func hours(_ hours: Int) -> Self {
Self(seconds: hours*60*60)
}
}
}
74 changes: 74 additions & 0 deletions Sources/WebPush/VAPID/VAPIDKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// VAPIDKey.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-04.
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
//

@preconcurrency import Crypto
import Foundation

extension VoluntaryApplicationServerIdentification {
public struct Key: Sendable {
private var privateKey: P256.Signing.PrivateKey

public init() {
privateKey = P256.Signing.PrivateKey(compactRepresentable: false)
}

public init(privateKey: P256.Signing.PrivateKey) {
self.privateKey = privateKey
}
}
}

extension VAPID.Key: Hashable {
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.privateKey.rawRepresentation == rhs.privateKey.rawRepresentation
}

public func hash(into hasher: inout Hasher) {
hasher.combine(privateKey.rawRepresentation)
}
}

extension VAPID.Key: Codable {
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
privateKey = try P256.Signing.PrivateKey(rawRepresentation: container.decode(Data.self))
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(privateKey.rawRepresentation)
}
}

extension VAPID.Key: Identifiable {
public struct ID: Hashable, Comparable, Codable, Sendable {
private var rawValue: String

init(_ rawValue: String) {
self.rawValue = rawValue
}

public static func < (lhs: Self, rhs: Self) -> Bool {
lhs.rawValue < rhs.rawValue
}

public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
self.rawValue = try container.decode(String.self)
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.rawValue)
}
}

public var id: ID {
ID(privateKey.publicKey.x963Representation.base64EncodedString()) // TODO: make url-safe
}
}

0 comments on commit 02f27b6

Please sign in to comment.