From 0fba28fa253d567cf03265197f84cc2bd974342b Mon Sep 17 00:00:00 2001 From: Juri Pakaste Date: Sun, 28 Apr 2024 18:58:58 +0300 Subject: [PATCH 1/3] Add Fernet implementation --- Sources/CryptoSwift/Fernet.swift | 190 +++++++++++++++++++++++ Tests/CryptoSwiftTests/FernetTests.swift | 33 ++++ 2 files changed, 223 insertions(+) create mode 100644 Sources/CryptoSwift/Fernet.swift create mode 100644 Tests/CryptoSwiftTests/FernetTests.swift diff --git a/Sources/CryptoSwift/Fernet.swift b/Sources/CryptoSwift/Fernet.swift new file mode 100644 index 00000000..3d7c8e41 --- /dev/null +++ b/Sources/CryptoSwift/Fernet.swift @@ -0,0 +1,190 @@ +import Foundation + +/// Fernet provides support for the [fernet](https://github.com/fernet/spec) encryption format. +public struct Fernet { + let makeDate: () -> Date + let makeIV: (Int) -> [UInt8] + let signingKey: Data + let encryptionKey: Data + + /// Initialize Fernet with a Base64URL encoded key. + public init( + encodedKey: Data, + makeDate: @escaping () -> Date = Date.init, + makeIV: @escaping (Int) -> [UInt8] = AES.randomIV + ) throws { + guard let fernetKey = Data(base64URLData: encodedKey) else { throw KeyError.invalidFormat } + try self.init(key: fernetKey, makeDate: makeDate, makeIV: makeIV) + } + + /// Initialize Fernet with raw, unencoded key. + public init( + key: Data, + makeDate: @escaping () -> Date = Date.init, + makeIV: @escaping (Int) -> [UInt8] = AES.randomIV + ) throws { + guard key.count == 32 else { throw KeyError.invalidLength } + self.makeDate = makeDate + self.makeIV = makeIV + self.signingKey = key.prefix(16) + self.encryptionKey = key.suffix(16) + } + + /// Decode fernet data. + public func decode(_ encoded: Data) throws -> DecodeOutput { + guard let fernetToken = Data(base64URLData: encoded) else { throw DecodingError.tokenDecodingFailed } + + guard fernetToken.count >= 73 && (fernetToken.count - 57) % 16 == 0 else { + throw DecodingError.invalidTokenFormat + } + let version = fernetToken[0] + let timestamp = fernetToken[1 ..< 9] + let iv = fernetToken[9 ..< 25] + let ciphertext = fernetToken[25 ..< fernetToken.count - 32] + let hmac = fernetToken[fernetToken.count - 32 ..< fernetToken.count] + + guard version == 128 else { throw DecodingError.unknownVersion } + let plaintext = try decrypt(ciphertext: ciphertext, key: self.encryptionKey, iv: iv) + let hmacMatches = try verifyHMAC( + hmac, + authenticating: Data([version]) + timestamp + iv + ciphertext, + using: self.signingKey + ) + + return DecodeOutput(data: plaintext, hmacSuccess: hmacMatches) + } + + /// Encode data in the fernet format. + public func encode(_ data: Data) throws -> Data { + let timestamp: [UInt8] = { + let now = self.makeDate() + let timestamp = Int(now.timeIntervalSince1970).bigEndian + return withUnsafeBytes(of: timestamp, Array.init) + }() + guard case let iv = self.makeIV(16), iv.count == 16 else { throw EncodingError.invalidIV } + let ciphertext: [UInt8] + do { + let aes = try AES(key: self.encryptionKey.bytes, blockMode: CBC(iv: iv), padding: .pkcs7) + ciphertext = try aes.encrypt(data.bytes) + } catch { + throw EncodingError.aesError(error) + } + let version: [UInt8] = [0x80] + let hmac = try makeVerificationHMAC(data: Data(version + timestamp + iv + ciphertext), key: self.signingKey) + let fernetToken = (version + timestamp + iv + ciphertext + hmac).base64URLEncodedData() + return fernetToken + } +} + +extension Fernet { + /// Errors encountered while processing the fernet key. + public enum KeyError: Error { + case invalidFormat + case invalidLength + } + + /// Errors encountered while decoding data. + public enum DecodingError: Error { + case aesError(any Error) + case hmacError(any Error) + case invalidTokenFormat + case keyDecodingFailed + case tokenDecodingFailed + case unknownVersion + } + + /// Errors encountered while encoding data. + public enum EncodingError: Error { + case aesError(any Error) + case hmacError(any Error) + case invalidIV + } + + /// Decoding result. + public struct DecodeOutput { + /// Decoded data. + var data: Data + /// A boolean indicating if HMAC verification was successful. + var hmacSuccess: Bool + } +} + +private func computeHMAC(data: Data, key: Data) throws -> Data { + Data(try HMAC(key: key.bytes, variant: .sha2(.sha256)).authenticate(data.bytes)) +} + +private func decrypt(ciphertext: Data, key: Data, iv: Data) throws -> Data { + do { + let aes = try AES(key: key.bytes, blockMode: CBC(iv: iv.bytes), padding: .pkcs7) + let decryptedData = try aes.decrypt(ciphertext.bytes) + return Data(decryptedData) + } catch { + throw Fernet.DecodingError.aesError(error) + } +} + +private func makeVerificationHMAC(data: Data, key: Data) throws -> Data { + do { + return try computeHMAC(data: data, key: key) + } catch { + throw Fernet.EncodingError.hmacError(error) + } +} + +private func verifyHMAC(_ mac: Data, authenticating data: Data, using key: Data) throws -> Bool { + do { + let auth = try computeHMAC(data: data, key: key) + return constantTimeEquals(auth, mac) + } catch { + throw Fernet.DecodingError.hmacError(error) + } +} + +// Who knows how the compiler will optimize this but at least try to be constant time. +private func constantTimeEquals(_ lhs: C1, _ rhs: C2) -> Bool +where C1: Collection, + C2: Collection, + C1.Element == UInt8, + C2.Element == UInt8 +{ + guard lhs.count == rhs.count else { return false } + return zip(lhs, rhs).reduce(into: 0) { output, pair in output |= pair.0 ^ pair.1 } == 0 +} + +private extension Data { + init?(base64URLData base64: Data) { + var decoded = base64.map { b in + switch b { + case ASCII.dash.rawValue: ASCII.plus.rawValue + case ASCII.underscore.rawValue: ASCII.slash.rawValue + default: b + } + } + while decoded.count % 4 != 0 { + decoded.append(ASCII.equals.rawValue) + } + self.init(base64Encoded: Data(decoded)) + } + + func base64URLEncodedData() -> Data { + let bytes = self.base64EncodedData() + .compactMap { b in + switch b { + case ASCII.plus.rawValue: ASCII.dash.rawValue + case ASCII.slash.rawValue: ASCII.underscore.rawValue + case ASCII.equals.rawValue: nil + default: b + } + } + return Data(bytes) + } +} + +private enum ASCII: UInt8 { + case plus = 43 + case dash = 45 + case slash = 47 + case equals = 61 + case underscore = 95 +} + diff --git a/Tests/CryptoSwiftTests/FernetTests.swift b/Tests/CryptoSwiftTests/FernetTests.swift new file mode 100644 index 00000000..1ccebe5e --- /dev/null +++ b/Tests/CryptoSwiftTests/FernetTests.swift @@ -0,0 +1,33 @@ +@testable import CryptoSwift +import XCTest + +final class FernetTests: XCTestCase { + func testEncode() throws { + let key = "3b-Nqg6ry-jrAuDyVjSwEe8wrdyEPQfPuOQNH1q5olE=" + let plaintext = "my deep dark secret" + + let now = Date(timeIntervalSince1970: 1_627_721_798) + let iv: [UInt8] = [41, 44, 26, 236, 9, 110, 52, 150, 33, 193, 102, 135, 173, 1, 176, 0] + + let fernet = try Fernet( + encodedKey: Data(key.utf8), + makeDate: { now }, + makeIV: { _ in iv } + ) + let encoded = try fernet.encode(Data(plaintext.utf8)) + + XCTAssertEqual( + String(data: encoded, encoding: .utf8), + "gAAAAABhBRBGKSwa7AluNJYhwWaHrQGwAA8UpMH8Wtw3tEoTD2E_-nbeoAvxbtBpFiC0ZjbVne_ZetFinKSyMjxwWaPRnXVSVqz5QqpUXp6h-34_TL7BaDs" + ) + } + + func testDecode() throws { + let key = "3b-Nqg6ry-jrAuDyVjSwEe8wrdyEPQfPuOQNH1q5olE" + let encrypted = "gAAAAABhBRBGKSwa7AluNJYhwWaHrQGwAA8UpMH8Wtw3tEoTD2E_-nbeoAvxbtBpFiC0ZjbVne_ZetFinKSyMjxwWaPRnXVSVqz5QqpUXp6h-34_TL7BaDs" + let fernet = try Fernet(encodedKey: Data(key.utf8)) + let decoded = try fernet.decode(Data(encrypted.utf8)) + XCTAssertEqual(String(data: decoded.data, encoding: .utf8), "my deep dark secret") + XCTAssertTrue(decoded.hmacSuccess) + } +} From c539f5a596a1d935df6fd9b57b60b2da1683bf86 Mon Sep 17 00:00:00 2001 From: Juri Pakaste Date: Sun, 28 Apr 2024 19:03:15 +0300 Subject: [PATCH 2/3] Add standard headers to new files --- Sources/CryptoSwift/Fernet.swift | 37 +++++++++++++++++------- Tests/CryptoSwiftTests/FernetTests.swift | 23 ++++++++++++--- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/Sources/CryptoSwift/Fernet.swift b/Sources/CryptoSwift/Fernet.swift index 3d7c8e41..4ccc8572 100644 --- a/Sources/CryptoSwift/Fernet.swift +++ b/Sources/CryptoSwift/Fernet.swift @@ -1,3 +1,18 @@ +// +// CryptoSwift +// +// Copyright (C) Marcin Krzyżanowski +// This software is provided 'as-is', without any express or implied warranty. +// +// In no event will the authors be held liable for any damages arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose,including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: +// +// - The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation is required. +// - Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. +// - This notice may not be removed or altered from any source or binary distribution. +// + import Foundation /// Fernet provides support for the [fernet](https://github.com/fernet/spec) encryption format. @@ -6,7 +21,7 @@ public struct Fernet { let makeIV: (Int) -> [UInt8] let signingKey: Data let encryptionKey: Data - + /// Initialize Fernet with a Base64URL encoded key. public init( encodedKey: Data, @@ -16,7 +31,7 @@ public struct Fernet { guard let fernetKey = Data(base64URLData: encodedKey) else { throw KeyError.invalidFormat } try self.init(key: fernetKey, makeDate: makeDate, makeIV: makeIV) } - + /// Initialize Fernet with raw, unencoded key. public init( key: Data, @@ -29,11 +44,11 @@ public struct Fernet { self.signingKey = key.prefix(16) self.encryptionKey = key.suffix(16) } - + /// Decode fernet data. public func decode(_ encoded: Data) throws -> DecodeOutput { guard let fernetToken = Data(base64URLData: encoded) else { throw DecodingError.tokenDecodingFailed } - + guard fernetToken.count >= 73 && (fernetToken.count - 57) % 16 == 0 else { throw DecodingError.invalidTokenFormat } @@ -42,7 +57,7 @@ public struct Fernet { let iv = fernetToken[9 ..< 25] let ciphertext = fernetToken[25 ..< fernetToken.count - 32] let hmac = fernetToken[fernetToken.count - 32 ..< fernetToken.count] - + guard version == 128 else { throw DecodingError.unknownVersion } let plaintext = try decrypt(ciphertext: ciphertext, key: self.encryptionKey, iv: iv) let hmacMatches = try verifyHMAC( @@ -50,10 +65,10 @@ public struct Fernet { authenticating: Data([version]) + timestamp + iv + ciphertext, using: self.signingKey ) - + return DecodeOutput(data: plaintext, hmacSuccess: hmacMatches) } - + /// Encode data in the fernet format. public func encode(_ data: Data) throws -> Data { let timestamp: [UInt8] = { @@ -82,7 +97,7 @@ extension Fernet { case invalidFormat case invalidLength } - + /// Errors encountered while decoding data. public enum DecodingError: Error { case aesError(any Error) @@ -92,14 +107,14 @@ extension Fernet { case tokenDecodingFailed case unknownVersion } - + /// Errors encountered while encoding data. public enum EncodingError: Error { case aesError(any Error) case hmacError(any Error) case invalidIV } - + /// Decoding result. public struct DecodeOutput { /// Decoded data. @@ -165,7 +180,7 @@ private extension Data { } self.init(base64Encoded: Data(decoded)) } - + func base64URLEncodedData() -> Data { let bytes = self.base64EncodedData() .compactMap { b in diff --git a/Tests/CryptoSwiftTests/FernetTests.swift b/Tests/CryptoSwiftTests/FernetTests.swift index 1ccebe5e..3e6987fb 100644 --- a/Tests/CryptoSwiftTests/FernetTests.swift +++ b/Tests/CryptoSwiftTests/FernetTests.swift @@ -1,3 +1,18 @@ +// +// CryptoSwift +// +// Copyright (C) Marcin Krzyżanowski +// This software is provided 'as-is', without any express or implied warranty. +// +// In no event will the authors be held liable for any damages arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose,including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: +// +// - The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation is required. +// - Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. +// - This notice may not be removed or altered from any source or binary distribution. +// + @testable import CryptoSwift import XCTest @@ -5,23 +20,23 @@ final class FernetTests: XCTestCase { func testEncode() throws { let key = "3b-Nqg6ry-jrAuDyVjSwEe8wrdyEPQfPuOQNH1q5olE=" let plaintext = "my deep dark secret" - + let now = Date(timeIntervalSince1970: 1_627_721_798) let iv: [UInt8] = [41, 44, 26, 236, 9, 110, 52, 150, 33, 193, 102, 135, 173, 1, 176, 0] - + let fernet = try Fernet( encodedKey: Data(key.utf8), makeDate: { now }, makeIV: { _ in iv } ) let encoded = try fernet.encode(Data(plaintext.utf8)) - + XCTAssertEqual( String(data: encoded, encoding: .utf8), "gAAAAABhBRBGKSwa7AluNJYhwWaHrQGwAA8UpMH8Wtw3tEoTD2E_-nbeoAvxbtBpFiC0ZjbVne_ZetFinKSyMjxwWaPRnXVSVqz5QqpUXp6h-34_TL7BaDs" ) } - + func testDecode() throws { let key = "3b-Nqg6ry-jrAuDyVjSwEe8wrdyEPQfPuOQNH1q5olE" let encrypted = "gAAAAABhBRBGKSwa7AluNJYhwWaHrQGwAA8UpMH8Wtw3tEoTD2E_-nbeoAvxbtBpFiC0ZjbVne_ZetFinKSyMjxwWaPRnXVSVqz5QqpUXp6h-34_TL7BaDs" From 331b4f165aeb56199f1b33b27e4c70756b7b7480 Mon Sep 17 00:00:00 2001 From: Juri Pakaste Date: Sun, 28 Apr 2024 19:05:46 +0300 Subject: [PATCH 3/3] Run SwiftFormat on the new files --- Sources/CryptoSwift/Fernet.swift | 48 ++++++++++++------------ Tests/CryptoSwiftTests/FernetTests.swift | 2 +- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/Sources/CryptoSwift/Fernet.swift b/Sources/CryptoSwift/Fernet.swift index 4ccc8572..156f34a1 100644 --- a/Sources/CryptoSwift/Fernet.swift +++ b/Sources/CryptoSwift/Fernet.swift @@ -41,8 +41,8 @@ public struct Fernet { guard key.count == 32 else { throw KeyError.invalidLength } self.makeDate = makeDate self.makeIV = makeIV - self.signingKey = key.prefix(16) - self.encryptionKey = key.suffix(16) + signingKey = key.prefix(16) + encryptionKey = key.suffix(16) } /// Decode fernet data. @@ -53,17 +53,17 @@ public struct Fernet { throw DecodingError.invalidTokenFormat } let version = fernetToken[0] - let timestamp = fernetToken[1 ..< 9] - let iv = fernetToken[9 ..< 25] - let ciphertext = fernetToken[25 ..< fernetToken.count - 32] - let hmac = fernetToken[fernetToken.count - 32 ..< fernetToken.count] + let timestamp = fernetToken[1..<9] + let iv = fernetToken[9..<25] + let ciphertext = fernetToken[25..(_ lhs: C1, _ rhs: C2) -> Bool -where C1: Collection, - C2: Collection, - C1.Element == UInt8, - C2.Element == UInt8 -{ + where C1: Collection, + C2: Collection, + C1.Element == UInt8, + C2.Element == UInt8 { guard lhs.count == rhs.count else { return false } return zip(lhs, rhs).reduce(into: 0) { output, pair in output |= pair.0 ^ pair.1 } == 0 } @@ -170,9 +169,9 @@ private extension Data { init?(base64URLData base64: Data) { var decoded = base64.map { b in switch b { - case ASCII.dash.rawValue: ASCII.plus.rawValue - case ASCII.underscore.rawValue: ASCII.slash.rawValue - default: b + case ASCII.dash.rawValue: ASCII.plus.rawValue + case ASCII.underscore.rawValue: ASCII.slash.rawValue + default: b } } while decoded.count % 4 != 0 { @@ -182,13 +181,13 @@ private extension Data { } func base64URLEncodedData() -> Data { - let bytes = self.base64EncodedData() + let bytes = base64EncodedData() .compactMap { b in switch b { - case ASCII.plus.rawValue: ASCII.dash.rawValue - case ASCII.slash.rawValue: ASCII.underscore.rawValue - case ASCII.equals.rawValue: nil - default: b + case ASCII.plus.rawValue: ASCII.dash.rawValue + case ASCII.slash.rawValue: ASCII.underscore.rawValue + case ASCII.equals.rawValue: nil + default: b } } return Data(bytes) @@ -202,4 +201,3 @@ private enum ASCII: UInt8 { case equals = 61 case underscore = 95 } - diff --git a/Tests/CryptoSwiftTests/FernetTests.swift b/Tests/CryptoSwiftTests/FernetTests.swift index 3e6987fb..5e270a92 100644 --- a/Tests/CryptoSwiftTests/FernetTests.swift +++ b/Tests/CryptoSwiftTests/FernetTests.swift @@ -13,8 +13,8 @@ // - This notice may not be removed or altered from any source or binary distribution. // -@testable import CryptoSwift import XCTest +@testable import CryptoSwift final class FernetTests: XCTestCase { func testEncode() throws {