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

CryptorModule #144

Merged
merged 16 commits into from
Oct 16, 2023
Merged

CryptorModule #144

merged 16 commits into from
Oct 16, 2023

Conversation

jguz-pubnub
Copy link
Contributor

@jguz-pubnub jguz-pubnub commented Sep 25, 2023

fix(crypto): Improve security of crypto implementation

Improved security of crypto implementation by adding AES-CBC cryptor

feat(crypto): Add CryptorModule

Add CryptorModule that allows configuring SDK to encrypt and decrypt messages

* Reusing existing CryptoStream + CryptoInputStream by removing Crypto dependency from them
* Moved old encryption/decryption to LegacyCryptor
* Introduced new AESCBCCryptor
* Introduced new CryptorModule instead of Crypto
* Fixing tests
Comment on lines 108 to 110
case encryptionError
case decryptionError
case unknownCryptorError
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jguz-pubnub Should we try to be consistent with other cases in class? The class itself already specifies that it will be errors, and the *Error suffix won't have much meaning. Other cases which report any troubles have *Failure suffix.

Comment on lines 35 to 41
public override func setup() {
startCucumberHookEventsListening()

var cryptorKind: String = ""
var cipherKey: String = ""
var randomIV: Bool = true
var otherCryptors: [String] = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setup called only once, and those variables won't be reset for each scenario. I suggest moving them into handleBeforeHook:

Suggested change
public override func setup() {
startCucumberHookEventsListening()
var cryptorKind: String = ""
var cipherKey: String = ""
var randomIV: Bool = true
var otherCryptors: [String] = []
var cryptorKind: String!
var cipherKey: String!
var randomIV: Bool = true
var otherCryptors: [String] = []
override public func handleBeforeHook() {
cryptorKind = ""
cipherKey = ""
randomIV = true
otherCryptors = []
super.handleBeforeHook()
}
public override func setup() {
startCucumberHookEventsListening()

Comment on lines 60 to 82
When("I decrypt '(.*)' file") { args, userInfo in
let fileName = args?.first ?? ""
let localUrl = self.localUrl(for: fileName)
let inputStream = InputStream(url: localUrl)!
let outputUrl = self.generateTestOutputUrl()

let cryptorModule = self.createCryptorModule(cryptorKind, key: cipherKey, withRandomIV: randomIV)
let encryptedStreamData = EncryptedStreamResult(stream: inputStream, contentLength: localUrl.sizeOf)
let decryptingRes = cryptorModule.decrypt(stream: encryptedStreamData, to: outputUrl)

Then("I receive '(.*)'") { thenArgs, _ in
switch thenArgs?.first ?? "" {
case "unknown cryptor error":
XCTAssertTrue(self.failureIfAny(from: decryptingRes)?.reason == .unknownCryptorError)
case "decryption error":
XCTAssertTrue(self.failureIfAny(from: decryptingRes)?.reason == .decryptionError)
case "success":
XCTAssertNotNil(try? decryptingRes.get())
default:
XCTFail("Unsupported outcome")
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be like follows and decryptingRes be declared as instance variable (as above):

Suggested change
When("I decrypt '(.*)' file") { args, userInfo in
let fileName = args?.first ?? ""
let localUrl = self.localUrl(for: fileName)
let inputStream = InputStream(url: localUrl)!
let outputUrl = self.generateTestOutputUrl()
let cryptorModule = self.createCryptorModule(cryptorKind, key: cipherKey, withRandomIV: randomIV)
let encryptedStreamData = EncryptedStreamResult(stream: inputStream, contentLength: localUrl.sizeOf)
let decryptingRes = cryptorModule.decrypt(stream: encryptedStreamData, to: outputUrl)
Then("I receive '(.*)'") { thenArgs, _ in
switch thenArgs?.first ?? "" {
case "unknown cryptor error":
XCTAssertTrue(self.failureIfAny(from: decryptingRes)?.reason == .unknownCryptorError)
case "decryption error":
XCTAssertTrue(self.failureIfAny(from: decryptingRes)?.reason == .decryptionError)
case "success":
XCTAssertNotNil(try? decryptingRes.get())
default:
XCTFail("Unsupported outcome")
}
}
}
When("I decrypt '(.*)' file") { args, userInfo in
let fileName = args?.first ?? ""
let localUrl = self.localUrl(for: fileName)
let inputStream = InputStream(url: localUrl)!
let outputUrl = self.generateTestOutputUrl()
let cryptorModule = self.createCryptorModule(cryptorKind, key: cipherKey, withRandomIV: randomIV)
let encryptedStreamData = EncryptedStreamResult(stream: inputStream, contentLength: localUrl.sizeOf)
let decryptingRes = cryptorModule.decrypt(stream: encryptedStreamData, to: outputUrl)
}
Then("I receive '(.*)'") { thenArgs, _ in
switch thenArgs?.first ?? "" {
case "unknown cryptor error":
XCTAssertTrue(self.failureIfAny(from: decryptingRes)?.reason == .unknownCryptorError)
case "decryption error":
XCTAssertTrue(self.failureIfAny(from: decryptingRes)?.reason == .decryptionError)
case "success":
XCTAssertNotNil(try? decryptingRes.get())
default:
XCTFail("Unsupported outcome")
}
}

Comment on lines 83 to 100
When("I encrypt '(.*)' file as 'binary'") { args, userInfo in
let fileName = args?.first ?? ""
let cryptorModule = self.createCryptorModule(cryptorKind, key: cipherKey, withRandomIV: randomIV)
let localFileUrl = self.localUrl(for: fileName)
let inputData = try! Data(contentsOf: localFileUrl)
let encryptedDataRes = cryptorModule.encrypt(data: inputData)

Then("Successfully decrypt an encrypted file with legacy code") { _, _ in
let decryptedData = try! cryptorModule.decrypt(data: try! encryptedDataRes.get()).get()
XCTAssertEqual(inputData, decryptedData)
}
Then("I receive 'encryption error'") { _, _ in
guard case .failure(let failure) = encryptedDataRes else {
XCTFail("Encryption error is expected"); return;
}
XCTAssertTrue(failure.reason == .encryptionError)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that it is possible to declare it this way, but it makes create false impression that nested steps definitions will be related only to this When:

Suggested change
When("I encrypt '(.*)' file as 'binary'") { args, userInfo in
let fileName = args?.first ?? ""
let cryptorModule = self.createCryptorModule(cryptorKind, key: cipherKey, withRandomIV: randomIV)
let localFileUrl = self.localUrl(for: fileName)
let inputData = try! Data(contentsOf: localFileUrl)
let encryptedDataRes = cryptorModule.encrypt(data: inputData)
Then("Successfully decrypt an encrypted file with legacy code") { _, _ in
let decryptedData = try! cryptorModule.decrypt(data: try! encryptedDataRes.get()).get()
XCTAssertEqual(inputData, decryptedData)
}
Then("I receive 'encryption error'") { _, _ in
guard case .failure(let failure) = encryptedDataRes else {
XCTFail("Encryption error is expected"); return;
}
XCTAssertTrue(failure.reason == .encryptionError)
}
}
When("I encrypt '(.*)' file as 'binary'") { args, userInfo in
let fileName = args?.first ?? ""
let cryptorModule = self.createCryptorModule(cryptorKind, key: cipherKey, withRandomIV: randomIV)
let localFileUrl = self.localUrl(for: fileName)
let inputData = try! Data(contentsOf: localFileUrl)
let encryptedDataRes = cryptorModule.encrypt(data: inputData)
}
Then("Successfully decrypt an encrypted file with legacy code") { _, _ in
let decryptedData = try! cryptorModule.decrypt(data: try! encryptedDataRes.get()).get()
XCTAssertEqual(inputData, decryptedData)
}
Then("I receive 'encryption error'") { _, _ in
guard case .failure(let failure) = encryptedDataRes else {
XCTFail("Encryption error is expected"); return;
}
XCTAssertTrue(failure.reason == .encryptionError)
}

if a suggestion will be included, it will require that encryptedDataRes to be declared as instance variable.

public var cipherKey: Crypto?
public var cryptorModule: CryptorModule?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this change be a breaking change?
Can we mark it as deprecated and, if users provide it, use Crypto field values to construct CryptoModule under the hood and use it further with endpoints?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be possible I think, will restore Crypto and its fields

iv = try CryptorVector.fixed.data()
cipherText = data.data
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have some more check here for case, when cipherText is empty after we extracted IV from it?

default:
continuationStream = MultipartInputStream(
inputStreams: [
InputStream(data: possibleHeaderBytes.suffix(from: noOfBytesProcessedByParser)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just use noOfBytesProcessedByParser here instead of suffix offset computation? Shouldn't this variable store data which hasn't been processed as header?


/// A stream that provides read-only stream functionality while performing crypto operations
public class CryptoInputStream: InputStream {
// swiftlint:disable:previous type_body_length

public struct DataSource {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, right now CrytoInputStream will work only for our cryptors? In future, users also would like to have some help from use in handling stream chunks reading.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still preserved this class as public so customers can use it. What's changed is the input that this class takes. I decoupled "old" Crypto from this class so now customers may create its instances without any dependencies to struct/classes we're going to deprecate.

let blockSize: Int

func outputSize(from inputBytes: Int) -> Int {
inputBytes + (blockSize - (inputBytes % blockSize))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CCCrypto actually can tell you what resulting size you will get (depending on from operation type and source data size).

if includeInitializationVectorInContent {
rawDataLength = contentLength - (operation == .decrypt ? crypto.cipher.blockSize : 0)
// The estimated content length is the IV length plus the crypted length
estimatedCryptoCount = crypto.cipher.blockSize + crypto.cipher.outputSize(from: rawDataLength)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above, CCCrypto can help with estimated output size computation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it from the current master implementation that's available here. The reason I moved this code was not to alter/modify CryptoStream or CryptoInputStream already present before the more it's necessary.

CryptoStream looks like a wrapper for Swift's CommonCrypto operations. In general, I agree with you it's fine to use CommonCrypto's operations directly in the CryptoInputStream implementation - since CryptoInputStream is already tightly coupled with Swift's CommonCrypto, so IMHO there's nothing that CryptoStream abstracts away.

I can try to use CCCOperations directly in CryptoInputStream, but I'm not sure yet what would be the scope of changes after introducing this idea.

* Fixing readability of contract tests
* Fixing reasons (PubNubError) for Crypto errors
* Fixed representation for the CryptorHeader v1
* Restoring PubNub's extension for encryption/decryption
* Restoring original Crypto
* Returning MultipartInputStream as the result of stream encryption
* Removing EncryptedStreamDataResult
* Fixed encrypt() and decrypt() methods for PubNub class
* Removed encoding from CryptorModule
Sources/PubNub/Helpers/Crypto/CryptoError.swift Outdated Show resolved Hide resolved
Sources/PubNub/Helpers/Crypto/CryptorModule.swift Outdated Show resolved Hide resolved
/// - Returns: A success, storing an ``EncryptedStreamResult`` value if operation succeeds. Otherwise, a failure storing `PubNubError` is returned
public func encrypt(stream: InputStream, contentLength: Int) -> Result<EncryptedStreamResult, PubNubError> {
/// - Returns: A success, storing a `MultipartInputStream` value if operation succeeds. Otherwise, a failure storing `PubNubError` is returned
public func encrypt(stream: InputStream, contentLength: Int) -> Result<MultipartInputStream, PubNubError> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible in case of success to return InputStream (caller shouldn't be aware about actual implementation)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is also used for File uploading, so we also need to have encrypted stream length to set proper value for HTTP Content-Header field. Where do you store such length?

@@ -28,16 +28,18 @@
import Foundation

/// An `InputStream` that can combine multiple streams into a single stream
class MultipartInputStream: InputStream {
public class MultipartInputStream: InputStream {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to make it public? Will be there any benefits for users to using it as MultipartInputStream?

Earlier, this class was created to provide support of multipart-form body, which may consist of sections represented by separate streams with data.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it was your idea during the last review 🙂 The whole issue is about setting Content-Length when uploading a File, so CryptorModule method responsible for stream encryption has to return something more than InputStream only. Where do you prefer having encrypted stream length stored?

Comment on lines +50 to +54
guard headerLength + dataLength < buffer.count else {
throw PubNubError(.decryptionFailure, additional: ["Cannot extract metadata for CryptorHeader v1"])
}
// Extracts Cryptor-defined data from the supplied buffer
cryptorDefinedData = buffer.subdata(in: headerLength..<headerLength + dataLength)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note, that there can be no cryptor-defined data at all or custom implementation will handle it in a different way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I will rework that when implementing another Cryptor. Fortunately, that's the internal implementation so it won't break customers in terms of any API. The question is where do you extract metadata for V1 header? Is it inside CryptorModule or somewhere else?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since CryptoModule after data pre-processing (header extraction) calls to actual cryptor implementations which expect specific types (encrypt expects Data and decrypt expects EncrpyptedData which contains cryptor-defined portion of data and encrypted data). It is module who extracts metadata and puts it into EncrpyptedData.

jguz-pubnub and others added 3 commits October 5, 2023 12:06
* Provide minimum input for old Crypto to stay backward-compatible
* InputStream instead of MultipartInputStream
@jguz-pubnub jguz-pubnub removed the request for review from josh-lubliner October 5, 2023 12:52
Copy link
Contributor

@parfeon parfeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@jguz-pubnub
Copy link
Contributor Author

@pubnub-release-bot release as v6.2.0

@jguz-pubnub jguz-pubnub merged commit 56c8687 into master Oct 16, 2023
10 checks passed
@jguz-pubnub jguz-pubnub deleted the feat/cryptors branch October 16, 2023 11:57
@pubnub-release-bot
Copy link

🛑 Build failed. Please check release build for details 🛑

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants