-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
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 fernet #1047
Open
juri
wants to merge
3
commits into
krzyzanowskim:main
Choose a base branch
from
juri:fernet
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Add support for fernet #1047
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
// | ||
// CryptoSwift | ||
// | ||
// Copyright (C) Marcin Krzyżanowski <marcin@krzyzanowskim.com> | ||
// 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. | ||
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 | ||
signingKey = key.prefix(16) | ||
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: encryptionKey, iv: iv) | ||
let hmacMatches = try verifyHMAC( | ||
hmac, | ||
authenticating: Data([version]) + timestamp + iv + ciphertext, | ||
using: 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 = makeIV(16), iv.count == 16 else { throw EncodingError.invalidIV } | ||
let ciphertext: [UInt8] | ||
do { | ||
let aes = try AES(key: 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: 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<C1, C2>(_ 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 = 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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
// | ||
// CryptoSwift | ||
// | ||
// Copyright (C) Marcin Krzyżanowski <marcin@krzyzanowskim.com> | ||
// 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 XCTest | ||
@testable import CryptoSwift | ||
|
||
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) | ||
} | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CryptoSwift already has base64 utilities that can be used instead, can't it?