Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VAPID Authentication Header #2

Merged
merged 1 commit into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")
}
}
Loading