From 34411b0729ae443448a9283cf001116d20c0581f Mon Sep 17 00:00:00 2001 From: amika-sq <88001738+amika-sq@users.noreply.github.com> Date: Tue, 27 Feb 2024 09:28:05 -0700 Subject: [PATCH] Implement JWT signing (#22) JWT signatures are required for accessing authenticated tbDEX endpoints. Currently, there isn't a need for Swift to verify a JWT, as that is done on the server side only, so that is not implemented yet. --- Sources/Web5/Common/ISO8601Date.swift | 30 +++++++ Sources/Web5/Crypto/JOSE/JWS.swift | 37 +++++++-- Sources/Web5/Crypto/JOSE/JWT.swift | 93 ++++++++++++++++++++++ Tests/Web5Tests/Crypto/JOSE/JWSTests.swift | 12 +-- Tests/Web5Tests/Crypto/JOSE/JWTTests.swift | 15 ++++ 5 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 Sources/Web5/Common/ISO8601Date.swift create mode 100644 Sources/Web5/Crypto/JOSE/JWT.swift create mode 100644 Tests/Web5Tests/Crypto/JOSE/JWTTests.swift diff --git a/Sources/Web5/Common/ISO8601Date.swift b/Sources/Web5/Common/ISO8601Date.swift new file mode 100644 index 0000000..c395bcf --- /dev/null +++ b/Sources/Web5/Common/ISO8601Date.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Wrapper used to easily encode a `Date` to and decode a `Date` from an ISO 8601 formatted date string. +@propertyWrapper +struct ISO8601Date: Codable { + var wrappedValue: Date? + + init(wrappedValue: Date?) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer() + let stringValue = try value.decode(String.self) + if let date = ISO8601DateFormatter().date(from: stringValue) { + wrappedValue = date + } else { + throw DecodingError.typeMismatch(Date.self, DecodingError.Context(codingPath: [], debugDescription: "Failed to decode ISO Date. Invalid string format.")) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if let wrappedValue { + let string = ISO8601DateFormatter().string(from: wrappedValue) + try container.encode(string) + } + } +} diff --git a/Sources/Web5/Crypto/JOSE/JWS.swift b/Sources/Web5/Crypto/JOSE/JWS.swift index 6f8e27a..ad4bb14 100644 --- a/Sources/Web5/Crypto/JOSE/JWS.swift +++ b/Sources/Web5/Crypto/JOSE/JWS.swift @@ -107,22 +107,42 @@ public struct JWS { } } + /// Options that can be used to configure the Sign operation of a JWS + public struct SignOptions { + /// Boolean determining if the payload is detached or not in the resulting JWS signature + public let detached: Bool + + /// Optional `VerificationMethod` ID to use for signing. If not provided, the first + /// verificationMethod in the `BearerDID`'s document will be used. + public let verificationMethodID: String? + + // Optional type of verification method to use for signing. If not provided, the first + public let type: String? + + public init( + detached: Bool = false, + verificationMethodID: String? = nil, + type: String? = nil + ) { + self.detached = detached + self.verificationMethodID = verificationMethodID + self.type = type + } + } + /// Signs the provided payload with a key associated with the provided `BearerDID`. /// - Parameters: /// - did: `BearerDID` to use for signing /// - payload: Data to be signed - /// - detached: If true, the payload will not be included in the resulting compactJWS - /// - verificationID: Optional `VerificationMethod` ID to use for signing. If not provided, the first - /// assertionMethod in the `BearerDID`'s document will be used. + /// - options: Options to configure the signing operation /// - Returns: compactJWS representation of the signed payload public static func sign( did: BearerDID, payload: D, - detached: Bool, - verificationMethodID: String? = nil + options: SignOptions ) throws -> String where D: DataProtocol { - let signer = try did.getSigner(verificationMethodID: verificationMethodID) + let signer = try did.getSigner(verificationMethodID: options.verificationMethodID) guard let publicKey = signer.verificationMethod.publicKeyJwk else { throw Error.signError("Public key not found") @@ -134,7 +154,8 @@ public struct JWS { let header = Header( algorithm: algorithm.jwsAlgorithm, - keyID: signer.verificationMethod.id + keyID: signer.verificationMethod.id, + type: options.type ) let base64UrlEncodedHeader = try JSONEncoder().encode(header).base64UrlEncodedString() @@ -143,7 +164,7 @@ public struct JWS { let base64UrlEncodedSignature = try signer.sign(payload: Data(toSign.utf8)).base64UrlEncodedString() let compactJWS: String - if detached { + if options.detached { compactJWS = "\(base64UrlEncodedHeader)..\(base64UrlEncodedSignature)" } else { compactJWS = "\(base64UrlEncodedHeader).\(base64UrlEncodedPayload).\(base64UrlEncodedSignature)" diff --git a/Sources/Web5/Crypto/JOSE/JWT.swift b/Sources/Web5/Crypto/JOSE/JWT.swift new file mode 100644 index 0000000..b1d9d3a --- /dev/null +++ b/Sources/Web5/Crypto/JOSE/JWT.swift @@ -0,0 +1,93 @@ +import Foundation + +public struct JWT { + + /// Claims represents JWT (JSON Web Token) Claims + /// + /// See [RFC7519](https://tools.ietf.org/html/rfc7519#section-4) for more information + public struct Claims: Codable { + /// The "iss" (issuer) claim identifies the principal that issued the JWT. + /// + /// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1) + let issuer: String? + + /// The "sub" (subject) claim identifies the principal that is the subject of the JWT. + /// + /// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2) + let subject: String? + + /// The "aud" (audience) claim identifies the recipients that the JWT is intended for. + /// + /// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3) + let audience: String? + + /// The "exp" (expiration time) claim identifies the expiration time on + /// or after which the JWT must not be accepted for processing. + /// + /// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4) + @ISO8601Date private(set) var expiration: Date? + + /// The "nbf" (not before) claim identifies the time before which the JWT + /// must not be accepted for processing. + /// + /// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5) + @ISO8601Date private(set) var notBefore: Date? + + /// The "iat" (issued at) claim identifies the time at which the JWT was issued. + /// + /// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6) + @ISO8601Date private(set) var issuedAt: Date? + + /// The "jti" (JWT ID) claim provides a unique identifier for the JWT. + /// + /// [Spec](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7) + let jwtID: String? + + // Default Initializer + public init( + issuer: String? = nil, + subject: String? = nil, + audience: String? = nil, + expiration: Date? = nil, + notBefore: Date? = nil, + issuedAt: Date? = nil, + jwtID: String? = nil + ) { + self.issuer = issuer + self.subject = subject + self.audience = audience + self.expiration = expiration + self.notBefore = notBefore + self.issuedAt = issuedAt + self.jwtID = jwtID + } + + enum CodingKeys: String, CodingKey { + case issuer = "iss" + case subject = "sub" + case audience = "aud" + case expiration = "exp" + case notBefore = "nbf" + case issuedAt = "iat" + case jwtID = "jti" + } + } + + /// Signs the provied JWT claims with the provided BearerDID. + /// - Parameters: + /// - did: The BearerDID to sign the JWT with + /// - claims: The claims to sign + /// - Returns: The signed JWT + public static func sign(did: BearerDID, claims: Claims) throws -> String { + let payload = try JSONEncoder().encode(claims) + + return try JWS.sign( + did: did, + payload: payload, + options: .init( + detached: false, + type: "JWT" + ) + ) + } +} diff --git a/Tests/Web5Tests/Crypto/JOSE/JWSTests.swift b/Tests/Web5Tests/Crypto/JOSE/JWSTests.swift index 4fb3570..84356ab 100644 --- a/Tests/Web5Tests/Crypto/JOSE/JWSTests.swift +++ b/Tests/Web5Tests/Crypto/JOSE/JWSTests.swift @@ -8,7 +8,7 @@ final class JWSTests: XCTestCase { let payload = "Hello, World!".data(using: .utf8)! func test_sign_detachedPayload() throws { - let compactJWS = try JWS.sign(did: did, payload: payload, detached: true) + let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: true)) let compactJWSParts = compactJWS.split(separator: ".", omittingEmptySubsequences: false) XCTAssertEqual(compactJWSParts.count, 3) @@ -17,7 +17,7 @@ final class JWSTests: XCTestCase { } func test_sign_attachedPayload() throws { - let compactJWS = try JWS.sign(did: did, payload: payload, detached: false) + let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: false)) let compactJWSParts = compactJWS.split(separator: ".", omittingEmptySubsequences: false) XCTAssertEqual(compactJWSParts.count, 3) @@ -26,28 +26,28 @@ final class JWSTests: XCTestCase { } func test_verify_detachedPayload() async throws { - let compactJWS = try JWS.sign(did: did, payload: payload, detached: true) + let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: true)) let isValid = try await JWS.verify(compactJWS: compactJWS, detachedPayload: payload) XCTAssertTrue(isValid) } func test_verify_attachedPayload() async throws { - let compactJWS = try JWS.sign(did: did, payload: payload, detached: false) + let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: false)) let isValid = try await JWS.verify(compactJWS: compactJWS) XCTAssertTrue(isValid) } func test_verify_expectedSigningDIDURI_match() async throws { - let compactJWS = try JWS.sign(did: did, payload: payload, detached: false) + let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: false)) let isValid = try await JWS.verify(compactJWS: compactJWS, expectedSigningDIDURI: did.uri) XCTAssertTrue(isValid) } func test_verify_expectedSigningDIDURI_noMatch() async throws { - let compactJWS = try JWS.sign(did: did, payload: payload, detached: false) + let compactJWS = try JWS.sign(did: did, payload: payload, options: .init(detached: false)) let isValid = try await JWS.verify(compactJWS: compactJWS, expectedSigningDIDURI: "did:example:1234") // compactJWS was signed by `did`, but we're expecting it to be signed by a different DID. diff --git a/Tests/Web5Tests/Crypto/JOSE/JWTTests.swift b/Tests/Web5Tests/Crypto/JOSE/JWTTests.swift new file mode 100644 index 0000000..64720ca --- /dev/null +++ b/Tests/Web5Tests/Crypto/JOSE/JWTTests.swift @@ -0,0 +1,15 @@ +import XCTest + +@testable import Web5 + +final class JWTTests: XCTestCase { + + func test_sign() throws { + let did = try DIDJWK.create(keyManager: InMemoryKeyManager()) + + let claims = JWT.Claims(issuer: did.identifier) + let jwt = try JWT.sign(did: did, claims: claims) + + XCTAssertFalse(jwt.isEmpty) + } +}