Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
with_api_check: ${{ github.event_name == 'pull_request' }}
warnings_as_errors: true
with_linting: true
with_windows: true
with_windows: false
with_musl: true
with_android: true
ios_scheme_name: jwt-kit
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ let package = Package(
.library(name: "JWTKit", targets: ["JWTKit"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-crypto.git", from: "4.0.0"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "4.1.0"),
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.15.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
],
Expand Down
43 changes: 41 additions & 2 deletions Sources/JWTKit/EdDSA/EdDSA.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ extension EdDSA {
///
/// In JWT, EdDSA public keys are represented as a single x-coordinate and are used for verifying signatures.
/// Currently, only the ``EdDSACurve/ed25519`` curve is supported.
public struct PublicKey: EdDSAKey {
public struct PublicKey: EdDSAKey, Equatable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, why can't we also make them Hashable?

@ptoffy ptoffy Oct 30, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could if there's a use case for it. I wouldn't have made them Equatable either if

  1. We didn't need it in the tests
  2. RSA and ECDSA weren't also already Equatable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is generally useful to make all your types Hashable, if they are. We can't foresee every usecase, but if someone wants to put them in a Set… there you go.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all keys should conform to Hashable because… they are

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Equatable means "represents a value which can be meaningfully compared for sameness with another value".
Identifiable means "represents a value which has unique identity even if it compares the same with another value".
Hashable means "is Equatable and represents a value which can be quickly and efficiently reduced to a hashing key with appropriate properties".

There are very few things which are semantically Equatable without being semantically Hashable. In Swift, "not Hashable" is effectively used to mean "this type should not be treated as having sufficiently unique representation to be used as a dictionary key or to be uniqued by set inclusion".

As regards asymmetric encryption keys, Hashable is by this definition not semantically appropriate. You should not be keying a dictionary by a crypto key or trying to construct unique sets of crypto keys. In the infosec world, these would definitely qualify as potentially dangerous behaviors (in terms of reducing security).

let backing: Curve25519.Signing.PublicKey
let curve: EdDSACurve

Expand All @@ -33,6 +33,15 @@ extension EdDSA {
self.curve = .ed25519
}

/// Creates an ``EdDSA.PublicKey`` instance using the provided PEM
/// (Privacy Enhanced Mail) representation.
///
/// - Parameter pem: The PEM representation of the public key.
public init(pem string: String) throws {
self.backing = try .init(pemRepresentation: string)
self.curve = .ed25519
}

/// Creates an ``EdDSA.PublicKey`` instance using the public key x-coordinate and specified curve.
///
/// This init allows for the creation of an ``EdDSA.PublicKey`` using the x-coordinate of the public key.
Expand Down Expand Up @@ -61,9 +70,19 @@ extension EdDSA {
self.init(backing: key)
}

/// Raw bytes representation of the public key.
public var rawRepresentation: Data {
self.backing.rawRepresentation
}

/// PEM (Privacy Enhanced Mail) representation of the public key.
public var pemRepresentation: String {
self.backing.pemRepresentation
}

public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.backing.derRepresentation == rhs.backing.derRepresentation
}
}
}

Expand All @@ -72,7 +91,7 @@ extension EdDSA {
///
/// In JWT, EdDSA private keys are represented as a pair of x-coordinate and private key (d) and are used for signing.
/// Currently, only the ``Curve/ed25519`` curve is supported.
public struct PrivateKey: EdDSAKey {
public struct PrivateKey: EdDSAKey, Equatable {
let backing: Curve25519.Signing.PrivateKey
let curve: EdDSACurve

Expand All @@ -94,6 +113,15 @@ extension EdDSA {
self.init(backing: key)
}

/// Creates an ``EdDSA.PrivateKey`` instance using the provided PEM
/// (Privacy Enhanced Mail) representation.
///
/// - Parameter pem: The PEM representation of the private key.
public init(pem string: String) throws {
self.backing = try .init(pemRepresentation: string)
self.curve = .ed25519
}

/// Creates an ``EdDSA.PrivateKey`` instance using the provided private key.
///
/// This init constructs an ``EdDSA.PrivateKey`` based on the corresponding SwiftCrypto
Expand Down Expand Up @@ -132,12 +160,23 @@ extension EdDSA {
self.init(backing: key)
}

/// ``EdDSA.PublicKey`` associated with this private key.
public var publicKey: PublicKey {
.init(backing: self.backing.publicKey)
}

/// Raw bytes representation of the private key.
public var rawRepresentation: Data {
self.backing.rawRepresentation
}

/// PEM (Privacy Enhanced Mail) representation of the public key.
public var pemRepresentation: String {
self.backing.pemRepresentation
}

public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.backing.derRepresentation == rhs.backing.derRepresentation
}
}
}
6 changes: 2 additions & 4 deletions Sources/JWTKit/EdDSA/EdDSASigner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,14 @@ struct EdDSASigner: JWTAlgorithm, Sendable {

switch privateKey.curve.backing {
case .ed25519:
return try Curve25519.Signing.PrivateKey(rawRepresentation: privateKey.rawRepresentation)
.signature(for: plaintext).copyBytes()
return try privateKey.backing.signature(for: plaintext).copyBytes()
}
}

func verify(_ signature: some DataProtocol, signs plaintext: some DataProtocol) throws -> Bool {
switch publicKey.curve.backing {
case .ed25519:
try Curve25519.Signing.PublicKey(rawRepresentation: publicKey.rawRepresentation)
.isValidSignature(signature, for: plaintext)
publicKey.backing.isValidSignature(signature, for: plaintext)
}
}
}
74 changes: 49 additions & 25 deletions Tests/JWTKitTests/EdDSATests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import JWTKit

@Suite("EdDSA Tests")
struct EdDSATests {

@Test("Test EdDSA Generate")
func edDSAGenerate() async throws {
let payload = TestPayload(
Expand All @@ -24,9 +23,10 @@ struct EdDSATests {

@Test("Test EdDSA Public and Private")
func edDSAPublicPrivate() async throws {
let keys = try await JWTKeyCollection()
.add(eddsa: EdDSA.PublicKey(x: eddsaPublicKeyBase64, curve: .ed25519), kid: "public")
.add(eddsa: EdDSA.PrivateKey(d: eddsaPrivateKeyBase64, curve: .ed25519), kid: "private")
let signingCollection = try await JWTKeyCollection()
.add(eddsa: EdDSA.PrivateKey(d: eddsaPrivateKeyBase64, curve: .ed25519))
let verifyingCollection = try await JWTKeyCollection()
.add(eddsa: EdDSA.PublicKey(x: eddsaPublicKeyBase64, curve: .ed25519))
Comment on lines -27 to +29

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have these tests changed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because before it seemed like it was using the public key to verify but it wasn't. It wasn't wrong per se but a bit misleading


let payload = TestPayload(
sub: "vapor",
Expand All @@ -35,21 +35,14 @@ struct EdDSATests {
exp: .init(value: .init(timeIntervalSince1970: 2_000_000_000))
)

for _ in 0..<1000 {
let token = try await keys.sign(payload, kid: "private")
// test public signer decoding
let verifiedPayload = try await keys.verify(token, as: TestPayload.self)
#expect(verifiedPayload == payload)
}
let token = try await signingCollection.sign(payload, kid: "private")
// test public signer decoding
let verifiedPayload = try await verifyingCollection.verify(token, as: TestPayload.self)
#expect(verifiedPayload == payload)
}

@Test("Test Verifying EdDSA Key Using JWK")
func verifyingEdDSAKeyUsingJWK() async throws {
struct Foo: JWTPayload {
var bar: Int
func verify(using _: some JWTAlgorithm) throws {}
}

// ecdsa key in base64 format
let x = eddsaPublicKeyBase64
let d = eddsaPrivateKeyBase64
Expand Down Expand Up @@ -83,11 +76,6 @@ struct EdDSATests {

@Test("Test Verifying EdDSA Key Using JWK Base64URL")
func verifyingEdDSAKeyUsingJWKBase64URL() async throws {
struct Foo: JWTPayload {
var bar: Int
func verify(using _: some JWTAlgorithm) throws {}
}

let x = eddsaPublicKeyBase64Url
let d = eddsaPrivateKeyBase64Url

Expand Down Expand Up @@ -120,11 +108,6 @@ struct EdDSATests {

@Test("Test Verifying EdDSA Key Using JWK with Mixed Base64 Formats")
func verifyingEdDSAKeyUsingJWKWithMixedBase64Formats() async throws {
struct Foo: JWTPayload {
var bar: Int
func verify(using _: some JWTAlgorithm) throws {}
}

// eddsa key in base64url format
let x = eddsaPublicKeyBase64Url
let d = eddsaPrivateKeyBase64
Expand Down Expand Up @@ -155,10 +138,51 @@ struct EdDSATests {
let foo = try await keyCollection.verify(jwt, as: Foo.self)
#expect(foo.bar == 42)
}

@Test("Signing and Verifying with PEM")
func signingAndVerifyingWithPEM() async throws {
let signingKeyCollection = try await JWTKeyCollection()
.add(eddsa: EdDSA.PrivateKey(pem: eddsaPrivateKeyPEM))

let verificationKeyCollection = try await JWTKeyCollection()
.add(eddsa: EdDSA.PublicKey(pem: eddsaPublicKeyPEM))

let jwt = try await signingKeyCollection.sign(Foo(bar: 42))
let foo = try await verificationKeyCollection.verify(jwt, as: Foo.self)
#expect(foo.bar == 42)
}

@Test("PEM representation")
func pemRepresentation() async throws {
let privateKey = try EdDSA.PrivateKey(pem: eddsaPrivateKeyPEM)
let derivedPrivateKey = try EdDSA.PrivateKey(pem: privateKey.pemRepresentation)
#expect(privateKey == derivedPrivateKey)

let publicKey = try EdDSA.PublicKey(pem: eddsaPublicKeyPEM)
let derivedPublicKey = try EdDSA.PublicKey(pem: publicKey.pemRepresentation)
#expect(publicKey == derivedPublicKey)
}

struct Foo: JWTPayload {
var bar: Int
func verify(using _: some JWTAlgorithm) throws {}
}
}

let eddsaPublicKeyBase64 = "0ZcEvMCSYqSwR8XIkxOoaYjRQSAO8frTMSCpNbUl4lE="
let eddsaPrivateKeyBase64 = "d1H3/dcg0V3XyAuZW2TE5Z3rhY20M+4YAfYu/HUQd8w="
let eddsaPublicKeyBase64Url = "0ZcEvMCSYqSwR8XIkxOoaYjRQSAO8frTMSCpNbUl4lE"
let eddsaPrivateKeyBase64Url = "d1H3_dcg0V3XyAuZW2TE5Z3rhY20M-4YAfYu_HUQd8w"

let eddsaPrivateKeyPEM = """
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJXTkfKlQbhHjAcfEjQH9BMqUAxXalmgl1zi5q9zgSXB
-----END PRIVATE KEY-----
"""

let eddsaPublicKeyPEM = """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAHprHxN90GB+Kue+eXMpwuJc1xcouR1V3ZpVFLrsdgUQ=
-----END PUBLIC KEY-----
"""
#endif // canImport(Testing)