Skip to content

Commit

Permalink
feat(jws): add unencoded payload option
Browse files Browse the repository at this point in the history
Now it allows for unencoded payload JWS as specified in https://datatracker.ietf.org/doc/html/rfc7797
  • Loading branch information
beatt83 committed Jun 17, 2024
1 parent 682002a commit bde54fd
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 30 deletions.
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,12 @@ This library provides comprehensive support for the Jose suite of standards, inc
<tr><th>JWS Supported Types</th><th>JWS Supported Algorithms</th></tr>
<tr><td valign="top">

| 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:|

</td><td valign="top">

Expand All @@ -144,6 +145,8 @@ This library provides comprehensive support for the Jose suite of standards, inc

</td></tr> </table>

Note: JWS Unencoded payload as referenced in the [RFC-7797](https://datatracker.ietf.org/doc/html/rfc7797)

### JWK

<table>
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
10 changes: 5 additions & 5 deletions Sources/JSONWebEncryption/JWERegisteredFieldsHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -92,7 +92,7 @@ public protocol JWERegisteredFieldsHeader: JWARegisteredFieldsHeader {
x509CertificateSHA256Thumbprint: String?,
type: String?,
contentType: String?,
critical: String?,
critical: [String]?,
ephemeralPublicKey: JWK?,
agreementPartyUInfo: Data?,
agreementPartyVInfo: Data?,
Expand All @@ -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,
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -263,7 +263,7 @@ public struct DefaultJWEHeaderImpl: JWERegisteredFieldsHeader {
x509CertificateSHA256Thumbprint: String?,
type: String?,
contentType: String?,
critical: String?,
critical: [String]?,
ephemeralPublicKey: JWK?,
agreementPartyUInfo: Data?,
agreementPartyVInfo: Data?,
Expand Down
5 changes: 4 additions & 1 deletion Sources/JSONWebSignature/DefaultJWSHeaderImpl+Codable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}
}
25 changes: 22 additions & 3 deletions Sources/JSONWebSignature/JWS+Helper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: ".")
Expand All @@ -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
}
}
23 changes: 20 additions & 3 deletions Sources/JSONWebSignature/JWS+JsonFlattened.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
}
53 changes: 44 additions & 9 deletions Sources/JSONWebSignature/JWS+Sign.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<Key>(payload: Data, protectedHeaderData: Data, key: Key?) throws {
public init<Key>(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
Expand Down Expand Up @@ -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<Key>(payload: Data, protectedHeader: JWSRegisteredFieldsHeader, key: Key?) throws {
public init<Key>(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()
Expand All @@ -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).
Expand All @@ -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<Key>(payload: Data, key: Key) throws {
public init<Key>(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.
Expand Down Expand Up @@ -331,7 +342,31 @@ extension JWS {
}
}

private func prepareHeaderForJWK(header: Data, jwk: JWK?) throws -> Data {
func setHeaderForOptions<H: JWSRegisteredFieldsHeader>(header: Data, options: Set<JWSSignOptions>) 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
Expand Down
30 changes: 30 additions & 0 deletions Sources/JSONWebSignature/JWS+Verify.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import Foundation
import JSONWebAlgorithms
import JSONWebKey
import Tools

extension JWS {
/// Verifies the signature of the JWS using the provided key.
Expand Down Expand Up @@ -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<Key>(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<
Expand Down
11 changes: 8 additions & 3 deletions Sources/JSONWebSignature/JWSRegisteredFieldsHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -107,6 +111,7 @@ public struct DefaultJWSHeaderImpl: JWSRegisteredFieldsHeader {
self.type = type
self.contentType = contentType
self.critical = critical
self.base64EncodedUrlPayload = base64EncodedUrlPayload
}

public init(from: JWK) {
Expand Down
Loading

0 comments on commit bde54fd

Please sign in to comment.