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

Add support for JWS unencoded detached payloads #270

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
8 changes: 8 additions & 0 deletions JOSESwift.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
objects = {

/* Begin PBXBuildFile section */
5C0F564327FF18C8006328D1 /* JWSSigningInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0F564227FF18C8006328D1 /* JWSSigningInput.swift */; };
5C17840528181AD30072294E /* JWSUnencodedPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C17840428181AD30072294E /* JWSUnencodedPayloadTests.swift */; };
5FB760497BB7711EFB470B5A /* ECKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB76A41AECF99F3673C1C40 /* ECKeys.swift */; };
5FB76093CE81BF1F8E7C254A /* JWKECDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB7625CF5EF5D8F2135967F /* JWKECDecodingTests.swift */; };
5FB7628EC6EA2C4263853DE9 /* DataECPrivateKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB7604267B94DC5091D7105 /* DataECPrivateKey.swift */; };
Expand Down Expand Up @@ -139,6 +141,8 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
5C0F564227FF18C8006328D1 /* JWSSigningInput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JWSSigningInput.swift; sourceTree = "<group>"; };
5C17840428181AD30072294E /* JWSUnencodedPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWSUnencodedPayloadTests.swift; sourceTree = "<group>"; };
5FB7604267B94DC5091D7105 /* DataECPrivateKey.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataECPrivateKey.swift; sourceTree = "<group>"; };
5FB760DB390F90F91102DB74 /* EC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EC.swift; sourceTree = "<group>"; };
5FB7625CF5EF5D8F2135967F /* JWKECDecodingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JWKECDecodingTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -293,6 +297,7 @@
65125A311FBF85FA007CF3AE /* JWSDeserializationTests.swift */,
5FB765151D1E18BD7BFB95B8 /* JWSECTests.swift */,
7402BEB426288DCD0012801E /* JWSHMACTests.swift */,
5C17840428181AD30072294E /* JWSUnencodedPayloadTests.swift */,
);
name = JWS;
sourceTree = "<group>";
Expand Down Expand Up @@ -533,6 +538,7 @@
children = (
C85B1EF1204D82640026BDCB /* JWS.swift */,
6571F6221F7BF786004D53C5 /* JWSHeader.swift */,
5C0F564227FF18C8006328D1 /* JWSSigningInput.swift */,
C85B1EF3204D82860026BDCB /* Signer.swift */,
65D868101F7CEBA200769BBF /* Verifier.swift */,
);
Expand Down Expand Up @@ -777,6 +783,7 @@
65125A321FBF85FA007CF3AE /* JWSDeserializationTests.swift in Sources */,
65A103A3202B0CDF00D22BF5 /* DataRSAPublicKeyTests.swift in Sources */,
C84BDE171FAB1CB60002B5D0 /* RSASignerTests.swift in Sources */,
5C17840528181AD30072294E /* JWSUnencodedPayloadTests.swift in Sources */,
C8EE14541FAC797500A616E4 /* RSAVerifierTests.swift in Sources */,
658261492029E2D200B594ED /* SecKeyRSAPublicKeyTests.swift in Sources */,
C86AC8CB1FCEC20F0007E611 /* AESCBCContentEncryptionTests.swift in Sources */,
Expand Down Expand Up @@ -859,6 +866,7 @@
5FB7641E9D67F245F52F3A7E /* EC.swift in Sources */,
5FB762CE28963B4780B33973 /* SecKeyECPublicKey.swift in Sources */,
5FB76E6F2080BEC6B63939D6 /* ECSigner.swift in Sources */,
5C0F564327FF18C8006328D1 /* JWSSigningInput.swift in Sources */,
5FB76B8D7F1214FF01FA1A8B /* ECVerifier.swift in Sources */,
5FB763AFF6BE04E8A6AE4DFC /* DataECPublicKey.swift in Sources */,
5FB7628EC6EA2C4263853DE9 /* DataECPrivateKey.swift in Sources */,
Expand Down
55 changes: 54 additions & 1 deletion JOSESwift/Sources/JWS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import Foundation
internal enum JWSError: Error {
case algorithmMismatch
case cannotComputeSigningInput
case unencodedPayloadOptionMustNotBeUsedWithJWT
}

/// A JWS object consisting of a header, payload and signature. The three components of a JWS object
Expand Down Expand Up @@ -110,6 +111,52 @@ public struct JWS {
self = try JOSEDeserializer().deserialize(JWS.self, fromCompactSerialization: compactSerializationString)
}

/// Constructs a JWS object from a given compact serialization string and a [detached payload](https://www.rfc-editor.org/rfc/rfc7515.html#appendix-F).
///
/// If `compactSerialization` contains non-empty payload, `detachedPayload` is omitted.
///
/// - Parameters:
/// - compactSerialization: A compact serialized JWS object in string format as received e.g. from the server.
/// - detachedPayload: A detached payload delivered outside the JWS context.
/// - Throws:
/// - `JOSESwiftError.invalidCompactSerializationComponentCount(count: Int)`:
/// If the component count of the compact serialization is wrong.
/// - `JOSESwiftError.componentNotValidBase64URL(component: String)`:
/// If the component is not a valid base64URL string.
/// - `JOSESwiftError.componentCouldNotBeInitializedFromData(data: Data)`:
/// If a component cannot be initialized from its data object.
public init(compactSerialization: String, detachedPayload: Payload) throws {
let jws = try JOSEDeserializer().deserialize(JWS.self, fromCompactSerialization: compactSerialization)
if jws.payload.data().isEmpty {
self.init(header: jws.header, payload: detachedPayload, signature: jws.signature)
} else {
self = jws
}
}

/// Constructs a JWS object from a given compact serialization data and a [detached payload](https://www.rfc-editor.org/rfc/rfc7515.html#appendix-F).
///
/// If `compactSerialization` contains non-empty payload, `detachedPayload` is omitted.
///
/// - Parameters:
/// - compactSerialization: A compact serialized JWS object as data object as received e.g. from the server.
/// - detachedPayload: A detached payload delivered outside the JWS context.
/// - Throws:
/// - `JOSESwiftError.wrongDataEncoding(data: Data)`:
/// If the compact serialization data object is not convertible to string.
/// - `JOSESwiftError.invalidCompactSerializationComponentCount(count: Int)`:
/// If the component count of the compact serialization is wrong.
/// - `JOSESwiftError.componentNotValidBase64URL(component: String)`:
/// If the component is not a valid base64URL string.
/// - `JOSESwiftError.componentCouldNotBeInitializedFromData(data: Data)`:
/// If a component cannot be initialized from its data object.
public init(compactSerialization: Data, detachedPayload: Payload) throws {
guard let compactSerializationString = String(data: compactSerialization, encoding: .utf8) else {
throw JOSESwiftError.wrongDataEncoding(data: compactSerialization)
}
try self.init(compactSerialization: compactSerializationString, detachedPayload: detachedPayload)
}

fileprivate init(header: JWSHeader, payload: Payload, signature: Data) {
self.header = header
self.payload = payload
Expand Down Expand Up @@ -207,7 +254,13 @@ public struct JWS {
extension JWS: CompactSerializable {
public func serialize(to serializer: inout CompactSerializer) {
serializer.serialize(header)
serializer.serialize(payload)

if header.crit?.contains("b64") == true, header.b64 == false {
serializer.serialize(Data()) // Detached payload.
} else {
serializer.serialize(payload)
}

serializer.serialize(signature)
}
}
Expand Down
17 changes: 17 additions & 0 deletions JOSESwift/Sources/JWSHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,23 @@ extension JWSHeader: CommonHeaderParameterSpace {
}
}

/// The unencoded payload option.
///
/// Reference: [](https://datatracker.ietf.org/doc/html/rfc7797#section-3).
///
/// When set to `false` and `crit` contains `b64`, payload data is not encoded when computing the JWS signature.
///
/// **NOTE**: Due to [Unencoded Payload Content Restrictions](https://datatracker.ietf.org/doc/html/rfc7797#section-5),
/// [detached payload](https://www.rfc-editor.org/rfc/rfc7515.html#appendix-F) is always used when serializing `JWS`.
public var b64: Bool? {
get {
return parameters["b64"] as? Bool
}
set {
parameters["b64"] = newValue
}
}

/// The critical header parameter indicates the header parameter extensions.
public var crit: [String]? {
get {
Expand Down
42 changes: 42 additions & 0 deletions JOSESwift/Sources/JWSSigningInput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

struct JWSSigningInput {

let header: JWSHeader

let payload: Payload

func signingInput() throws -> Data {
let headerData = try computeHeaderData()
let payloadData = try computePayloadData()

// Force unwrapping is ok, since `".".data(using: .ascii)` should always work.
// swiftlint:disable:next force_unwrapping
return headerData + ".".data(using: .ascii)! + payloadData
}

private func computeHeaderData() throws -> Data {
guard let headerData = header.data().base64URLEncodedString().data(using: .ascii) else {
throw JWSError.cannotComputeSigningInput
}
return headerData
}

private func computePayloadData() throws -> Data {
let encodePayload = (header.crit?.contains("b64") == true)
? (header.b64 ?? true)
: true

if encodePayload {
guard let encodedPayload = payload.data().base64URLEncodedString().data(using: .ascii) else {
throw JWSError.cannotComputeSigningInput
}
return encodedPayload
} else if let typ = header.typ, typ.caseInsensitiveCompare("jwt") == .orderedSame {
throw JWSError.unencodedPayloadOptionMustNotBeUsedWithJWT
} else {
return payload.data()
}
}

}
14 changes: 1 addition & 13 deletions JOSESwift/Sources/Signer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,24 +77,12 @@ public struct Signer<KeyType> {
throw JWSError.algorithmMismatch
}

guard let signingInput = [header, payload].asJOSESigningInput() else {
throw JWSError.cannotComputeSigningInput
}
let signingInput = try JWSSigningInput(header: header, payload: payload).signingInput()

return try signer.sign(signingInput)
}
}

extension Array where Element == DataConvertible {
func asJOSESigningInput() -> Data? {
let encoded = self.map { component in
return component.data().base64URLEncodedString()
}

return encoded.joined(separator: ".").data(using: .ascii)
}
}

// MARK: - Deprecated API

extension Signer {
Expand Down
4 changes: 1 addition & 3 deletions JOSESwift/Sources/Verifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ public struct Verifier {
throw JWSError.algorithmMismatch
}

guard let signingInput = [header, payload].asJOSESigningInput() else {
throw JWSError.cannotComputeSigningInput
}
let signingInput = try JWSSigningInput(header: header, payload: payload).signingInput()

return try verifier.verify(signingInput, against: signature)
}
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ You can find detailed information about the relevant JOSE standards in the respe
- [RFC-7516:](https://tools.ietf.org/html/rfc7516) JSON Web Encryption (JWE)
- [RFC-7517:](https://tools.ietf.org/html/rfc7517) JSON Web Key (JWK)
- [RFC-7518:](https://tools.ietf.org/html/rfc7518) JSON Web Algorithms (JWA)
- [RFC-7797:](https://datatracker.ietf.org/doc/html/rfc7797) JSON Web Signature (JWS) Unencoded Payload Option

Don’t forget to check our [our wiki](https://github.com/mohemian/jose-ios/wiki) for more detailed documentation.

Expand Down
2 changes: 1 addition & 1 deletion Tests/ECVerifierTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class ECVerifierTests: ECCryptoTestCase {
let jws = try! JWS(compactSerialization: serializedJWS)
let verifier = ECVerifier(algorithm: algorithm, publicKey: keyData.publicKey)

guard let signingInput = [jws.header, jws.payload].asJOSESigningInput() else {
guard let signingInput = try? JWSSigningInput(header: jws.header, payload: jws.payload).signingInput() else {
XCTFail()
return false
}
Expand Down
7 changes: 6 additions & 1 deletion Tests/JWSHeaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ class JWSHeaderTests: XCTestCase {
let x5tS256 = "x5tS256"
let typ = "typ"
let cty = "cty"
let crit = ["crit1", "crit2"]
let b64 = false
let crit = ["crit1", "crit2", "b64"]

var header = JWSHeader(algorithm: .RS512)
header.jku = jku
Expand All @@ -123,6 +124,7 @@ class JWSHeaderTests: XCTestCase {
header.x5tS256 = x5tS256
header.typ = typ
header.cty = cty
header.b64 = b64
header.crit = crit

XCTAssertEqual(header.data().count, try! JSONSerialization.data(withJSONObject: header.parameters, options: []).count)
Expand Down Expand Up @@ -154,6 +156,9 @@ class JWSHeaderTests: XCTestCase {
XCTAssertEqual(header.parameters["cty"] as? String, cty)
XCTAssertEqual(header.cty, cty)

XCTAssertEqual(header.parameters["b64"] as? Bool, b64)
XCTAssertEqual(header.b64, b64)

XCTAssertEqual(header.parameters["crit"] as? [String], crit)
XCTAssertEqual(header.crit, crit)
}
Expand Down
111 changes: 96 additions & 15 deletions Tests/JWSSigningInputTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,105 @@ import XCTest

class JWSSigningInputTest: XCTestCase {

let header: JWSHeader = JWSHeader("{\"typ\":\"JWT\",\r\n \"alg\":\"HS256\"}".data(using: .utf8)!)!
let payload: Payload = Payload("{\"iss\":\"joe\",\r\n \"exp\":1300819380,\r\n \"http://example.com/is_root\":true}".data(using: .utf8)!)

let expectedSigningInput: [UInt8] = [
101, 121, 74, 48, 101, 88, 65, 105, 79, 105, 74, 75, 86, 49, 81,
105, 76, 65, 48, 75, 73, 67, 74, 104, 98, 71, 99, 105, 79, 105, 74,
73, 85, 122, 73, 49, 78, 105, 74, 57, 46, 101, 121, 74, 112, 99, 51,
77, 105, 79, 105, 74, 113, 98, 50, 85, 105, 76, 65, 48, 75, 73, 67,
74, 108, 101, 72, 65, 105, 79, 106, 69, 122, 77, 68, 65, 52, 77, 84,
107, 122, 79, 68, 65, 115, 68, 81, 111, 103, 73, 109, 104, 48, 100,
72, 65, 54, 76, 121, 57, 108, 101, 71, 70, 116, 99, 71, 120, 108, 76,
109, 78, 118, 98, 83, 57, 112, 99, 49, 57, 121, 98, 50, 57, 48, 73,
106, 112, 48, 99, 110, 86, 108, 102, 81
let payload = Payload("{\"iss\":\"joe\",\r\n \"exp\":1300819380,\r\n \"http://example.com/is_root\":true}".data(using: .utf8)!)

let expectedSeparatorBytes: [UInt8] = [46] // "."

let expectedEncodedPayloadBytes: [UInt8] = [
101, 121, 74, 112, 99, 51, 77, 105, 79, 105, 74, 113, 98, 50, 85, 105,
76, 65, 48, 75, 73, 67, 74, 108, 101, 72, 65, 105, 79, 106, 69, 122,
77, 68, 65, 52, 77, 84, 107, 122, 79, 68, 65, 115, 68, 81, 111, 103,
73, 109, 104, 48, 100, 72, 65, 54, 76, 121, 57, 108, 101, 71, 70, 116,
99, 71, 120, 108, 76, 109, 78, 118, 98, 83, 57, 112, 99, 49, 57, 121,
98, 50, 57, 48, 73, 106, 112, 48, 99, 110, 86, 108, 102, 81
]

func testSigningInputComputation() {
let signingInput: [UInt8] = Array([header, payload].asJOSESigningInput()!)
func testSigningInputComputation() throws {
let header: JWSHeader = JWSHeader("{\"typ\":\"JWT\",\r\n \"alg\":\"HS256\"}".data(using: .utf8)!)!

let expectedHeaderBytes: [UInt8] = [
101, 121, 74, 48, 101, 88, 65, 105, 79, 105, 74, 75, 86, 49, 81,
105, 76, 65, 48, 75, 73, 67, 74, 104, 98, 71, 99, 105, 79, 105, 74,
73, 85, 122, 73, 49, 78, 105, 74, 57
]
let expectedSigningInput = expectedHeaderBytes + expectedSeparatorBytes + expectedEncodedPayloadBytes

let signingInput: [UInt8] = Array(try JWSSigningInput(header: header, payload: payload).signingInput())
XCTAssertEqual(signingInput, expectedSigningInput)
}

func testSigningInputComputationWithUnencodedPayloadOptionDoesNotEncodePayload() throws {
let header: JWSHeader = JWSHeader("""
{
"alg": "HS256",
"b64": false,
"crit": [
"b64"
]
}
""".data(using: .utf8)!)!
let unencodedPayloadBytes: [UInt8] = Array(repeating: 0, count: 32)
let unencodedPayload: Payload = Payload(Data(unencodedPayloadBytes))

let expectedHeaderBytes: [UInt8] = [
101, 119, 111, 103, 73, 67, 74, 104, 98, 71, 99, 105, 79, 105, 65, 105,
83, 70, 77, 121, 78, 84, 89, 105, 76, 65, 111, 103, 73, 67, 74, 105,
78, 106, 81, 105, 79, 105, 66, 109, 89, 87, 120, 122, 90, 83, 119, 75,
73, 67, 65, 105, 89, 51, 74, 112, 100, 67, 73, 54, 73, 70, 115, 75,
73, 67, 65, 103, 73, 67, 74, 105, 78, 106, 81, 105, 67, 105, 65, 103,
88, 81, 112, 57
]
let expectedSigningInput = expectedHeaderBytes + expectedSeparatorBytes + unencodedPayloadBytes

let signingInput: [UInt8] = Array(try JWSSigningInput(header: header, payload: unencodedPayload).signingInput())

XCTAssertEqual(signingInput, expectedSigningInput)
}

func testSigningInputComputationWithExplicitEncodedPayloadOptionEncodesPayload() throws {
let header: JWSHeader = JWSHeader("""
{
"alg": "HS256",
"b64": true,
"crit": [
"b64"
]
}
""".data(using: .utf8)!)!

let expectedHeaderBytes: [UInt8] = [
101, 119, 111, 103, 73, 67, 74, 104, 98, 71, 99, 105, 79, 105, 65, 105,
83, 70, 77, 121, 78, 84, 89, 105, 76, 65, 111, 103, 73, 67, 74, 105,
78, 106, 81, 105, 79, 105, 66, 48, 99, 110, 86, 108, 76, 65, 111, 103,
73, 67, 74, 106, 99, 109, 108, 48, 73, 106, 111, 103, 87, 119, 111, 103,
73, 67, 65, 103, 73, 109, 73, 50, 78, 67, 73, 75, 73, 67, 66, 100,
67, 110, 48
]
let expectedSigningInput: [UInt8] = expectedHeaderBytes + expectedSeparatorBytes + expectedEncodedPayloadBytes

let signingInput: [UInt8] = Array(try JWSSigningInput(header: header, payload: payload).signingInput())

XCTAssertEqual(signingInput, expectedSigningInput)
}

func testSigningInputComputationWithUnencodedPayloadOptionAndJwtThrows() throws {
let header: JWSHeader = JWSHeader("""
{
"typ": "JWT",
"alg": "HS256",
"b64": false,
"crit": [
"b64"
]
}
""".data(using: .utf8)!)!

do {
_ = try JWSSigningInput(header: header, payload: payload).signingInput()
XCTFail("Expected to throw")
} catch {
XCTAssertEqual(error as? JWSError, JWSError.unencodedPayloadOptionMustNotBeUsedWithJWT)
}
}

}
Loading