Skip to content

Commit

Permalink
Added working VAPID authorization header generation
Browse files Browse the repository at this point in the history
  • Loading branch information
dimitribouniol committed Dec 8, 2024
1 parent 5b99b5d commit 39a6ee0
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 2 deletions.
15 changes: 14 additions & 1 deletion Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Foundation

extension DataProtocol {
@_disfavoredOverload
func base64URLEncodedString() -> String {
Data(self)
.base64EncodedString()
Expand All @@ -18,8 +19,20 @@ extension DataProtocol {
}
}

extension ContiguousBytes {
func base64URLEncodedString() -> String {
withUnsafeBytes { bytes in
Data(bytes)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
}

extension DataProtocol where Self: RangeReplaceableCollection {
init?(base64URLEncoded string: String) {
init?(base64URLEncoded string: some StringProtocol) {
var base64String = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
while base64String.count % 4 != 0 {
base64String = base64String.appending("=")
Expand Down
8 changes: 8 additions & 0 deletions Sources/WebPush/VAPID/VAPIDConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,16 @@ extension VoluntaryApplicationServerIdentification {
}

extension VAPID.Configuration {
/// The contact information for the push service.
///
/// This allows administrators of push services to contact you should an issue arise with your application server.
///
/// - Note: Although the specification notes that this field is optional, some push services may refuse connection from serers without contact information.
/// - SeeAlso: [RFC8292 Voluntary Application Server Identification (VAPID) for Web Push §2.1. Application Server Contact Information](https://datatracker.ietf.org/doc/html/rfc8292#section-2.1)
public enum ContactInformation: Hashable, Codable, Sendable {
/// A URL-based contact method, such as a support page on your website.
case url(URL)
/// An email-based contact method.
case email(String)

var urlString: String {
Expand Down
12 changes: 11 additions & 1 deletion Sources/WebPush/VAPID/VAPIDKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ extension VAPID.Key: Codable {
}

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

init(_ rawValue: String) {
Expand All @@ -66,9 +66,19 @@ extension VAPID.Key: Identifiable {
var container = encoder.singleValueContainer()
try container.encode(self.rawValue)
}

public var description: String {
self.rawValue
}
}

public var id: ID {
ID(privateKey.publicKey.x963Representation.base64URLEncodedString())
}
}

extension VAPID.Key: VAPIDKeyProtocol {
func signature(for message: some DataProtocol) throws -> P256.Signing.ECDSASignature {
try privateKey.signature(for: SHA256.hash(data: message))
}
}
87 changes: 87 additions & 0 deletions Sources/WebPush/VAPID/VAPIDToken.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// VAPIDToken.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-07.
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
//

@preconcurrency import Crypto
import Foundation

extension VAPID {
/// An internal representation the token and authorization headers used self-identification.
///
/// - SeeAlso: [RFC8292 Voluntary Application Server Identification (VAPID) for Web Push §2. Application Server Self-Identification](https://datatracker.ietf.org/doc/html/rfc8292#section-2)
struct Token: Hashable, Codable, Sendable {
enum CodingKeys: String, CodingKey {
case audience = "aud"
case subject = "sub"
case expiration = "exp"
}

var audience: String
var subject: VAPID.Configuration.ContactInformation
var expiration: Int

static let jwtHeader = Array(#"{"typ":"JWT","alg":"ES256"}"#.utf8).base64URLEncodedString()

init(
origin: String,
contactInformation: VAPID.Configuration.ContactInformation,
expiresIn: VAPID.Configuration.Duration
) {
audience = origin
subject = contactInformation
expiration = Int(Date.now.timeIntervalSince1970) + expiresIn.seconds
}

init?(token: String, key: String) {
let components = token.split(separator: ".")

guard
components.count == 3,
components[0] == Self.jwtHeader,
let bodyBytes = Data(base64URLEncoded: components[1]),
let signatureBytes = Data(base64URLEncoded: components[2]),
let publicKeyBytes = Data(base64URLEncoded: key)
else { return nil }

let message = Data("\(components[0]).\(components[1])".utf8)
let publicKey = try? P256.Signing.PublicKey(x963Representation: publicKeyBytes)
let isValid = try? publicKey?.isValidSignature(.init(rawRepresentation: signatureBytes), for: SHA256.hash(data: message))

guard
isValid == true,
let token = try? JSONDecoder().decode(Self.self, from: bodyBytes)
else { return nil }

self = token
}

func generateJWT(signedBy signingKey: some VAPIDKeyProtocol) throws -> String {
let header = Self.jwtHeader

let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let body = try encoder.encode(self).base64URLEncodedString()

var message = "\(header).\(body)"
let signature = try message.withUTF8 { try signingKey.signature(for: $0) }.base64URLEncodedString()
return "\(message).\(signature)"
}

func generateAuthorization(signedBy signingKey: some VAPIDKeyProtocol) throws -> String {
let token = try generateJWT(signedBy: signingKey)
let key = signingKey.id

return "vapid t=\(token), k=\(key)"
}
}
}

protocol VAPIDKeyProtocol: Identifiable, Sendable {
associatedtype Signature: ContiguousBytes

func signature(for message: some DataProtocol) throws -> Signature
}
69 changes: 69 additions & 0 deletions Tests/WebPushTests/VAPIDTokenTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// VAPIDTokenTests.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-07.
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
//

import Crypto
import Foundation
import Testing
@testable import WebPush

struct MockVAPIDKey<Bytes: ContiguousBytes & Sendable>: VAPIDKeyProtocol {
var id: String
var signature: Bytes

func signature(for message: some DataProtocol) throws -> Bytes {
signature
}
}

@Suite struct VAPIDTokenTests {
@Test func generatesValidSignedToken() throws {
let key = VAPID.Key()

let token = VAPID.Token(
origin: "https://push.example.net",
contactInformation: .email("[email protected]"),
expiresIn: .hours(22)
)

let signedJWT = try token.generateJWT(signedBy: key)
#expect(VAPID.Token(token: signedJWT, key: "\(key.id)") == token)
}

/// Make sure we can decode the example from https://datatracker.ietf.org/doc/html/rfc8292#section-2.4, as we use the same decoding logic to self-verify our own signing proceedure.
@Test func tokenVerificationMatchesSpec() throws {
var expectedToken = VAPID.Token(
origin: "https://push.example.net",
contactInformation: .email("[email protected]"),
expiresIn: 0
)
expectedToken.expiration = 1453523768

let receivedToken = VAPID.Token(
token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA",
key: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs"
)
#expect(receivedToken == expectedToken)
}

@Test func authorizationHeaderGeneration() throws {
var expectedToken = VAPID.Token(
origin: "https://push.example.net",
contactInformation: .email("[email protected]"),
expiresIn: 0
)
expectedToken.expiration = 1453523768

let mockKey = MockVAPIDKey(
id: "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs",
signature: Data(base64URLEncoded: "i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA")!
)

let generatedHeader = try expectedToken.generateAuthorization(signedBy: mockKey)
#expect(generatedHeader == "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA, k=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs")
}
}

0 comments on commit 39a6ee0

Please sign in to comment.