diff --git a/README.md b/README.md index aaebff5..e505538 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,12 @@ This library provides comprehensive support for the Jose suite of standards, inc JWS Supported TypesJWS Supported Algorithms -| Type | Supported | -|----------------|------------------| -| Compact String |:white_check_mark:| -| JSON |:white_check_mark:| -| JSON Flattened |:white_check_mark:| +| Type | Supported | +|---------------------|------------------| +| Compact String |:white_check_mark:| +| JSON |:white_check_mark:| +| JSON Flattened |:white_check_mark:| +| Unencoded Payload\* |:white_check_mark:| @@ -144,6 +145,8 @@ This library provides comprehensive support for the Jose suite of standards, inc +Note: JWS Unencoded payload as referenced in the [RFC-7797](https://datatracker.ietf.org/doc/html/rfc7797) + ### JWK @@ -298,6 +301,26 @@ let keyJWK = JWK(keyType: .rsa, algorithm: "RSA512", keyID: rsaKeyId, e: rsaKeyE let jwe = try JWS(payload: payload, protectedHeader: header, key: jwk) ``` +### JWS with Unencoded payload (Compact string only) + +JWS also supports unencoded payloads, which is useful in scenarios where the payload is already in a compact, URL-safe form (such as in the case of small JSON objects or base64url-encoded strings). This can help reduce the overall size of the JWS and improve performance by avoiding redundant encoding steps. + +To create a JWS with an unencoded payload, you need to set the b64 header parameter to false and ensure the payload is in a compatible format. + +Example: + +``` +let payload = "Hello world".data(using: .utf8)! +let key = secp256k1.Signing.PrivateKey() + +let jws = try JWS(payload: payload, key: key, options: [.unencodedPayload]) + +let jwsString = jws.compactSerialization + +try JWS.verify(jwsString: jwsString, payload: payload.data(using: .utf8)!, key: key) +``` + + ### JWE (JSON Web Encryption) JWE represents encrypted content using JSON-based data structures, following the guidelines of [RFC 7516](https://datatracker.ietf.org/doc/html/rfc7516). This module includes functionalities for encrypting and decrypting data, managing encryption keys, and handling various encryption algorithms and methods. diff --git a/Sources/JSONWebEncryption/DefaultJWEHeaderImpl+Codable.swift b/Sources/JSONWebEncryption/DefaultJWEHeaderImpl+Codable.swift index c249bdd..4f3cd86 100644 --- a/Sources/JSONWebEncryption/DefaultJWEHeaderImpl+Codable.swift +++ b/Sources/JSONWebEncryption/DefaultJWEHeaderImpl+Codable.swift @@ -94,7 +94,7 @@ extension DefaultJWEHeaderImpl: Codable { ephemeralPublicKey = try container.decodeIfPresent(JWK.self, forKey: .ephemeralPublicKey) type = try container.decodeIfPresent(String.self, forKey: .type) contentType = try container.decodeIfPresent(String.self, forKey: .contentType) - critical = try container.decodeIfPresent(String.self, forKey: .critical) + critical = try container.decodeIfPresent([String].self, forKey: .critical) senderKeyID = try container.decodeIfPresent(String.self, forKey: .senderKeyID) let initializationVectorBase64Url = try container.decodeIfPresent(String.self, forKey: .initializationVector) initializationVector = try initializationVectorBase64Url.map { try Base64URL.decode($0) } diff --git a/Sources/JSONWebEncryption/JWERegisteredFieldsHeader.swift b/Sources/JSONWebEncryption/JWERegisteredFieldsHeader.swift index 8992cef..b0a8a4f 100644 --- a/Sources/JSONWebEncryption/JWERegisteredFieldsHeader.swift +++ b/Sources/JSONWebEncryption/JWERegisteredFieldsHeader.swift @@ -58,7 +58,7 @@ public protocol JWERegisteredFieldsHeader: JWARegisteredFieldsHeader { var contentType: String? { get set } /// List of critical headers that must be understood and processed. - var critical: String? { get set } + var critical: [String]? { get set } /// Key ID of the sender's key, used in the `ECDH-1PU` key agreement algorithm. var senderKeyID: String? { get set } @@ -92,7 +92,7 @@ public protocol JWERegisteredFieldsHeader: JWARegisteredFieldsHeader { x509CertificateSHA256Thumbprint: String?, type: String?, contentType: String?, - critical: String?, + critical: [String]?, ephemeralPublicKey: JWK?, agreementPartyUInfo: Data?, agreementPartyVInfo: Data?, @@ -118,7 +118,7 @@ extension JWERegisteredFieldsHeader { x509CertificateSHA256Thumbprint: String? = nil, type: String? = nil, contentType: String? = nil, - critical: String? = nil, + critical: [String]? = nil, ephemeralPublicKey: JWK? = nil, agreementPartyUInfo: Data? = nil, agreementPartyVInfo: Data? = nil, @@ -227,7 +227,7 @@ public struct DefaultJWEHeaderImpl: JWERegisteredFieldsHeader { public var x509CertificateSHA256Thumbprint: String? public var type: String? public var contentType: String? - public var critical: String? + public var critical: [String]? public var ephemeralPublicKey: JWK? public var agreementPartyUInfo: Data? public var agreementPartyVInfo: Data? @@ -263,7 +263,7 @@ public struct DefaultJWEHeaderImpl: JWERegisteredFieldsHeader { x509CertificateSHA256Thumbprint: String?, type: String?, contentType: String?, - critical: String?, + critical: [String]?, ephemeralPublicKey: JWK?, agreementPartyUInfo: Data?, agreementPartyVInfo: Data?, diff --git a/Sources/JSONWebSignature/DefaultJWSHeaderImpl+Codable.swift b/Sources/JSONWebSignature/DefaultJWSHeaderImpl+Codable.swift index 41907a8..ce7718a 100644 --- a/Sources/JSONWebSignature/DefaultJWSHeaderImpl+Codable.swift +++ b/Sources/JSONWebSignature/DefaultJWSHeaderImpl+Codable.swift @@ -39,6 +39,7 @@ extension DefaultJWSHeaderImpl: Codable { case pbes2SaltInput = "p2s" case pbes2Count = "p2c" case senderKeyID = "skid" + case base64EncodedUrlPayload = "b64" } public func encode(to encoder: Encoder) throws { @@ -54,6 +55,7 @@ extension DefaultJWSHeaderImpl: Codable { try container.encodeIfPresent(type, forKey: .type) try container.encodeIfPresent(contentType, forKey: .contentType) try container.encodeIfPresent(critical, forKey: .critical) + try container.encodeIfPresent(base64EncodedUrlPayload, forKey: .base64EncodedUrlPayload) } public init(from decoder: Decoder) throws { @@ -68,6 +70,7 @@ extension DefaultJWSHeaderImpl: Codable { x509CertificateSHA256Thumbprint = try container.decodeIfPresent(String.self, forKey: .x509CertificateSHA256Thumbprint) type = try container.decodeIfPresent(String.self, forKey: .type) contentType = try container.decodeIfPresent(String.self, forKey: .contentType) - critical = try container.decodeIfPresent(String.self, forKey: .critical) + critical = try container.decodeIfPresent([String].self, forKey: .critical) + base64EncodedUrlPayload = try container.decodeIfPresent(Bool.self, forKey: .base64EncodedUrlPayload) } } diff --git a/Sources/JSONWebSignature/JWS+Helper.swift b/Sources/JSONWebSignature/JWS+Helper.swift index 0a5d559..a4c288c 100644 --- a/Sources/JSONWebSignature/JWS+Helper.swift +++ b/Sources/JSONWebSignature/JWS+Helper.swift @@ -19,6 +19,10 @@ import Tools extension JWS { static func buildSigningData(header: Data, data: Data) throws -> Data { + if try unencodedBase64Payload(header: header ) { + let headerB64 = Base64URL.encode(header) + return try [headerB64, data.tryToString()].joined(separator: ".").tryToData() + } guard let signingData = [header, data] .map({ Base64URL.encode($0) }) .joined(separator: ".") @@ -30,8 +34,23 @@ extension JWS { } static func buildJWSString(header: Data, data: Data, signature: Data) throws -> String { - return [header, data, signature] - .map({ Base64URL.encode($0) }) - .joined(separator: ".") + if try unencodedBase64Payload(header: header) { + return [header, Data(), signature] + .map({ Base64URL.encode($0) }) + .joined(separator: ".") + } else { + return [header, data, signature] + .map({ Base64URL.encode($0) }) + .joined(separator: ".") + } + } + + static func unencodedBase64Payload(header: Data) throws -> Bool { + let headerFields = try JSONDecoder.jwt.decode(DefaultJWSHeaderImpl.self, from: header) + guard + let hasBase64Header = headerFields.base64EncodedUrlPayload, + !hasBase64Header + else { return false } + return true } } diff --git a/Sources/JSONWebSignature/JWS+JsonFlattened.swift b/Sources/JSONWebSignature/JWS+JsonFlattened.swift index 3d03b72..60f47f7 100644 --- a/Sources/JSONWebSignature/JWS+JsonFlattened.swift +++ b/Sources/JSONWebSignature/JWS+JsonFlattened.swift @@ -173,7 +173,14 @@ extension JWSJsonFlattened: Codable { try container.encodeIfPresent(protectedHeaderData.map { Base64URL.encode($0) }, forKey: .protected) try container.encodeIfPresent(Base64URL.encode(signature), forKey: .signature) try container.encodeIfPresent(unprotectedHeader, forKey: .header) - try container.encode(Base64URL.encode(payload), forKey: .payload) + if + let headerData = protectedHeaderData, + try JWS.unencodedBase64Payload(header: headerData) + { + try container.encode(payload.tryToString().addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), forKey: .payload) + } else { + try container.encode(Base64URL.encode(payload), forKey: .payload) + } } public init(from decoder: Decoder) throws { @@ -190,7 +197,17 @@ extension JWSJsonFlattened: Codable { self.unprotectedHeaderData = try header.map { try JSONEncoder.jose.encode($0) } self.unprotectedHeader = header - let payloadBase64 = try container.decode(String.self, forKey: .payload) - self.payload = try Base64URL.decode(payloadBase64) + let payloadStr = try container.decode(String.self, forKey: .payload) + if + let headerData = protectedHeaderData, + try JWS.unencodedBase64Payload(header: headerData) + { + guard let payloadValue = payloadStr.removingPercentEncoding else { + throw JWS.JWSError.somethingWentWrong + } + self.payload = try payloadValue.tryToData() + } else { + self.payload = try Base64URL.decode(payloadStr) + } } } diff --git a/Sources/JSONWebSignature/JWS+Sign.swift b/Sources/JSONWebSignature/JWS+Sign.swift index 73fed19..553b5d8 100644 --- a/Sources/JSONWebSignature/JWS+Sign.swift +++ b/Sources/JSONWebSignature/JWS+Sign.swift @@ -19,6 +19,10 @@ import JSONWebAlgorithms import JSONWebKey import Tools +public enum JWSSignOptions { + case unencodedPayload +} + extension JWS { /// Initializes a new JWS (JSON Web Signature) instance with the given payload, protected header data, and key. /// @@ -33,10 +37,13 @@ extension JWS { /// - key: The cryptographic key used for signing, which can be of type `Data` and `KeyRepresentable`. /// /// - Throws: An error if the initialization or signing process fails. - public init(payload: Data, protectedHeaderData: Data, key: Key?) throws { + public init(payload: Data, protectedHeaderData: Data, key: Key?, options: [JWSSignOptions] = []) throws { let signature: Data let key = try key.map { try prepareJWK(header: protectedHeaderData, key: $0, isPrivate: true) } - let protectedHeader = try JSONDecoder().decode(DefaultJWSHeaderImpl.self, from: protectedHeaderData) + let (protectedHeader, protectedHeaderData): (DefaultJWSHeaderImpl, Data) = try setHeaderForOptions( + header: protectedHeaderData, + options: Set(options) + ) if let signer = protectedHeader.algorithm?.cryptoSigner { guard let key else { throw JWSError.missingKey @@ -65,15 +72,19 @@ extension JWS { /// - data: The payload data. /// - key: The cryptographic key used for signing, which can be of type `Data` and `KeyRepresentable`. /// - Throws: An error if the signing process fails, or if the key is missing. - public init(payload: Data, protectedHeader: JWSRegisteredFieldsHeader, key: Key?) throws { + public init(payload: Data, protectedHeader: JWSRegisteredFieldsHeader, key: Key?, options: [JWSSignOptions] = []) throws { let signature: Data let headerData = try JSONEncoder.jose.encode(protectedHeader) - let key = try key.map { try prepareJWK(header: headerData, key: $0, isPrivate: true) } + let (_, protectedHeaderData): (DefaultJWSHeaderImpl, Data) = try setHeaderForOptions( + header: headerData, + options: Set(options) + ) + let key = try key.map { try prepareJWK(header: protectedHeaderData, key: $0, isPrivate: true) } if let signer = protectedHeader.algorithm?.cryptoSigner { guard let key else { throw JWSError.missingKey } - let signingData = try JWS.buildSigningData(header: headerData, data: payload) + let signingData = try JWS.buildSigningData(header: protectedHeaderData, data: payload) signature = try signer.sign(data: signingData, key: key) } else { signature = Data() @@ -82,7 +93,7 @@ extension JWS { self.protectedHeader = protectedHeader self.payload = payload self.signature = signature - self.compactSerialization = try JWS.buildJWSString(header: headerData, data: payload, signature: signature) + self.compactSerialization = try JWS.buildJWSString(header: protectedHeaderData, data: payload, signature: signature) } /// Convenience initializer to create a `JWS` instance using payload data and a JSON Web Key (JWK). @@ -97,11 +108,11 @@ extension JWS { /// - data: The payload data. /// - key: The cryptographic key used for signing, which can be of type `Data` and `KeyRepresentable`. /// - Throws: An error if the signing process fails or if the key is inappropriate for the determined algorithm. - public init(payload: Data, key: Key) throws { + public init(payload: Data, key: Key, options: [JWSSignOptions] = []) throws { let jwkKey = try prepareJWK(header: nil, key: key) let algorithm = try jwkKey.signingAlgorithm() let header = DefaultJWSHeaderImpl(algorithm: algorithm) - try self.init(payload: payload, protectedHeader: header, key: key) + try self.init(payload: payload, protectedHeader: header, key: key, options: options) } /// Generates a JSON serialization of the JWS object with multiple signatures, each corresponding to a different key in the provided array. @@ -331,7 +342,31 @@ extension JWS { } } -private func prepareHeaderForJWK(header: Data, jwk: JWK?) throws -> Data { +func setHeaderForOptions(header: Data, options: Set) throws -> (H, Data) { + var headerChanges = header + try options.forEach { + switch $0 { + case .unencodedPayload: + headerChanges = try setUnencodedPayloadHeader(header: headerChanges) + } + } + let jwsFieldsHeader = try JSONDecoder.jwt.decode(H.self, from: headerChanges) + return (jwsFieldsHeader, headerChanges) +} + +func setUnencodedPayloadHeader(header: Data) throws -> Data { + guard + var json = try JSONSerialization.jsonObject(with: header) as? [String: Any] + else { throw JWS.JWSError.somethingWentWrong } + json["b64"] = false + var newCritical = (json["crit"] as? [String]).map { Set($0) } ?? Set() + newCritical.insert("b64") + json["crit"] = Array(newCritical) + let jsonData = try JSONSerialization.data(withJSONObject: json) + return jsonData +} + +func prepareHeaderForJWK(header: Data, jwk: JWK?) throws -> Data { if var jsonObj = try JSONSerialization.jsonObject(with: header) as? [String: Any], jsonObj["alg"] == nil diff --git a/Sources/JSONWebSignature/JWS+Verify.swift b/Sources/JSONWebSignature/JWS+Verify.swift index fe904d2..6ae4090 100644 --- a/Sources/JSONWebSignature/JWS+Verify.swift +++ b/Sources/JSONWebSignature/JWS+Verify.swift @@ -17,6 +17,7 @@ import Foundation import JSONWebAlgorithms import JSONWebKey +import Tools extension JWS { /// Verifies the signature of the JWS using the provided key. @@ -157,6 +158,35 @@ extension JWS { return try keys.contains { try JWS.verify(jwsJson: jwsJson, key: $0) } } } + + /// Verifies the signature of a JSON Web Signature (JWS) object when the payload is unencoded. + /// + /// This method handles JWS objects that have an unencoded payload, which is indicated by the `b64` + /// header parameter set to `false`. It first checks if the JWS header specifies an unencoded payload, + /// and then performs the verification accordingly. + /// + /// - Parameters: + /// - jwsString: The compact serialized JWS string. + /// - payload: The unencoded payload as `Data`. + /// - key: The cryptographic key used for signing, which can be of type `KeyRepresentable`. + /// + /// - Throws: An error if the verification process fails due to an invalid JWS format, missing key, or other issues. + /// - Returns: A Boolean value indicating whether the signature is valid (`true`) or not (`false`). + public static func verify(jwsString: String, payload: Data, key: Key?) throws -> Bool { + let components = jwsString.components(separatedBy: ".") + guard components.count == 3 else { + throw JWSError.invalidString + } + let header = try Base64URL.decode(components[0]) + guard try unencodedBase64Payload(header: header) else { + return try JWS(jwsString: jwsString).verify(key: key) + } + return try JWS( + protectedHeaderData: header, + data: payload, + signature: try Base64URL.decode(components[2]) + ).verify(key: key) + } } func decodeFullOrFlattenedJson< diff --git a/Sources/JSONWebSignature/JWSRegisteredFieldsHeader.swift b/Sources/JSONWebSignature/JWSRegisteredFieldsHeader.swift index 6b8a88f..63bc0aa 100644 --- a/Sources/JSONWebSignature/JWSRegisteredFieldsHeader.swift +++ b/Sources/JSONWebSignature/JWSRegisteredFieldsHeader.swift @@ -52,7 +52,9 @@ public protocol JWSRegisteredFieldsHeader: Codable { var contentType: String? { get set } /// Indicates extensions to this protocol that must be understood and processed. - var critical: String? { get set } + var critical: [String]? { get set } + + var base64EncodedUrlPayload: Bool? { get set } } /// `DefaultJWSHeaderImpl` is a default implementation of the `JWSProtectedFieldsHeader` protocol. @@ -68,7 +70,8 @@ public struct DefaultJWSHeaderImpl: JWSRegisteredFieldsHeader { public var x509CertificateSHA256Thumbprint: String? public var type: String? public var contentType: String? - public var critical: String? + public var critical: [String]? + public var base64EncodedUrlPayload: Bool? /// Initializes a new `DefaultJWSHeaderImpl` instance with optional parameters for each field. /// - Parameters: @@ -94,7 +97,8 @@ public struct DefaultJWSHeaderImpl: JWSRegisteredFieldsHeader { x509CertificateSHA256Thumbprint: String? = nil, type: String? = nil, contentType: String? = nil, - critical: String? = nil + critical: [String]? = nil, + base64EncodedUrlPayload: Bool? = nil ) { self.algorithm = algorithm self.keyID = keyID @@ -107,6 +111,7 @@ public struct DefaultJWSHeaderImpl: JWSRegisteredFieldsHeader { self.type = type self.contentType = contentType self.critical = critical + self.base64EncodedUrlPayload = base64EncodedUrlPayload } public init(from: JWK) { diff --git a/Tests/JWSTests/JWSTests.swift b/Tests/JWSTests/JWSTests.swift index 5edc748..f3de107 100644 --- a/Tests/JWSTests/JWSTests.swift +++ b/Tests/JWSTests/JWSTests.swift @@ -127,4 +127,12 @@ final class JWSTests: XCTestCase { let keyPair = JWK.testingCurve25519KPair XCTAssertThrowsError(try JWS(payload: "test".data(using: .utf8)!, protectedHeader: DefaultJWSHeaderImpl(algorithm: .ES512), key: keyPair)) } + + func testJWSUnencodedPayloadCompactString() throws { + let payload = "$.02" + let keyPair = JWK.testingES256Pair + let testJWS = try JWS(payload: payload.data(using: .utf8)!, key: keyPair, options: [.unencodedPayload]) + XCTAssertTrue(testJWS.compactSerialization.contains("..")) + XCTAssertTrue(try JWS.verify(jwsString: testJWS.compactSerialization, payload: payload.data(using: .utf8)!, key: keyPair)) + } }