diff --git a/CryptomatorCryptoLib.xcodeproj/project.pbxproj b/CryptomatorCryptoLib.xcodeproj/project.pbxproj index 1c522af..9cf7447 100644 --- a/CryptomatorCryptoLib.xcodeproj/project.pbxproj +++ b/CryptomatorCryptoLib.xcodeproj/project.pbxproj @@ -36,6 +36,9 @@ 9EB822C3248AF9C500879838 /* AesCtrTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EB822C2248AF9C500879838 /* AesCtrTests.swift */; }; 9EBEC947283782E6002210DE /* CtrCryptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBEC946283782E6002210DE /* CtrCryptorTests.swift */; }; 9EBEC94928378308002210DE /* GcmCryptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBEC94828378308002210DE /* GcmCryptorTests.swift */; }; + F92575B928912A59007089A4 /* StreamTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92575B828912A59007089A4 /* StreamTools.swift */; }; + F9328C762924E5C300E1F480 /* CryptoStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9328C752924E5C300E1F480 /* CryptoStreamTests.swift */; }; + F938A073289074DD001C19AC /* CryptoStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = F938A072289074DD001C19AC /* CryptoStream.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -101,6 +104,9 @@ 9EB822C2248AF9C500879838 /* AesCtrTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AesCtrTests.swift; sourceTree = ""; }; 9EBEC946283782E6002210DE /* CtrCryptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CtrCryptorTests.swift; sourceTree = ""; }; 9EBEC94828378308002210DE /* GcmCryptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GcmCryptorTests.swift; sourceTree = ""; }; + F92575B828912A59007089A4 /* StreamTools.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StreamTools.swift; sourceTree = ""; }; + F9328C752924E5C300E1F480 /* CryptoStreamTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CryptoStreamTests.swift; sourceTree = ""; }; + F938A072289074DD001C19AC /* CryptoStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoStream.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -164,6 +170,8 @@ 74F0F7592490C1EB00B4C26D /* CryptoSupport.swift */, 9E9BB8132454708600F9FF51 /* Masterkey.swift */, 74B4D38C2588CD60006C0567 /* MasterkeyFile.swift */, + F938A072289074DD001C19AC /* CryptoStream.swift */, + F92575B828912A59007089A4 /* StreamTools.swift */, ); path = CryptomatorCryptoLib; sourceTree = ""; @@ -180,6 +188,7 @@ 9EBEC94828378308002210DE /* GcmCryptorTests.swift */, 74A5B57525A869DD002D10F7 /* MasterkeyFileTests.swift */, 9E9BB81524558DFF00F9FF51 /* MasterkeyTests.swift */, + F9328C752924E5C300E1F480 /* CryptoStreamTests.swift */, ); path = CryptomatorCryptoLibTests; sourceTree = ""; @@ -437,7 +446,9 @@ 74F0F75A2490C1EB00B4C26D /* CryptoSupport.swift in Sources */, 9E9BB8142454708600F9FF51 /* Masterkey.swift in Sources */, 9E44EEA624599C6900A37B01 /* AesSiv.swift in Sources */, + F92575B928912A59007089A4 /* StreamTools.swift in Sources */, 9E97DC8D25F77BA40046C83E /* ContentCryptor.swift in Sources */, + F938A073289074DD001C19AC /* CryptoStream.swift in Sources */, 74F0F754248FC89B00B4C26D /* CryptoError.swift in Sources */, 74B4D38D2588CD60006C0567 /* MasterkeyFile.swift in Sources */, ); @@ -453,6 +464,7 @@ 9EB822C3248AF9C500879838 /* AesCtrTests.swift in Sources */, 74A5B57625A869DD002D10F7 /* MasterkeyFileTests.swift in Sources */, 9E9BB81624558DFF00F9FF51 /* MasterkeyTests.swift in Sources */, + F9328C762924E5C300E1F480 /* CryptoStreamTests.swift in Sources */, 9E35C4EB24576A3D0006E50C /* CryptorTests.swift in Sources */, 9EBEC94928378308002210DE /* GcmCryptorTests.swift in Sources */, ); diff --git a/Sources/CryptomatorCryptoLib/CryptoStream.swift b/Sources/CryptomatorCryptoLib/CryptoStream.swift new file mode 100644 index 0000000..0d376ab --- /dev/null +++ b/Sources/CryptomatorCryptoLib/CryptoStream.swift @@ -0,0 +1,186 @@ +// +// CryptoStream.swift +// +// Created by Julien Eyriès on 26/07/2022. +// Copyright © 2022 Julien Eyriès. All rights reserved. +// + +import Foundation + +public extension Cryptor { + func decryptInputStream(wrapped: InputStream) -> InputStream { + return CryptorDecryptInputStream(cryptor: self, wrapped: wrapped) + } + + func encryptOutputStream(wrapped: OutputStream) -> OutputStream { + return CryptorEncryptOutputStream(cryptor: self, wrapped: wrapped) + } +} + +final class CryptorDecryptInputStream: InputStream { + private let cryptor: Cryptor + private let wrapped: InputStream + private var error: Error? + private var header: FileHeader! + private var chunkNumber: UInt64 = 0 + private var cleartextChunk: [UInt8] = [] + + init(cryptor: Cryptor, wrapped: InputStream) { + self.cryptor = cryptor + self.wrapped = wrapped + super.init() + } + + override var streamStatus: Stream.Status { + return error != nil ? .error : wrapped.streamStatus + } + + override var streamError: Error? { + return error ?? wrapped.streamError + } + + override var hasBytesAvailable: Bool { + return error != nil ? false : wrapped.hasBytesAvailable + } + + override func schedule(in aRunLoop: RunLoop, forMode mode: RunLoop.Mode) { + wrapped.schedule(in: aRunLoop, forMode: mode) + } + + override func open() { + wrapped.open() + } + + override func close() { + wrapped.close() + } + + override func read(_ buffer: UnsafeMutablePointer, maxLength len: Int) -> Int { + if error != nil { + return -1 + } + + do { + if cleartextChunk.isEmpty { + try decrypt() + } + + let taken = min(len, cleartextChunk.count) + for i in 0 ..< taken { + buffer[i] = cleartextChunk[i] + } + + cleartextChunk = Array(cleartextChunk.suffix(from: taken)) + + return taken + + } catch { + self.error = error + return -1 + } + } + + private func decrypt() throws { + precondition(cleartextChunk.isEmpty) + + if header == nil { + let ciphertextHeader = try wrapped.readFullyIntoArray(maxLength: cryptor.fileHeaderSize) + guard ciphertextHeader.count == cryptor.fileHeaderSize else { + throw CryptoError.ioError + } + + header = try cryptor.decryptHeader(ciphertextHeader) + } + + let ciphertextChunk = try wrapped.readFullyIntoArray(maxLength: cryptor.ciphertextChunkSize) + guard !ciphertextChunk.isEmpty else { + return + } + + cleartextChunk = try cryptor.decryptSingleChunk(ciphertextChunk, + chunkNumber: chunkNumber, + headerNonce: header.nonce, + fileKey: header.contentKey) + chunkNumber += 1 + } +} + +final class CryptorEncryptOutputStream: OutputStream { + private let cryptor: Cryptor + private let wrapped: OutputStream + private var error: Error? + private var header: FileHeader! + private var chunkNumber: UInt64 = 0 + private var cleartextChunk: [UInt8] = [] + + init(cryptor: Cryptor, wrapped: OutputStream) { + self.cryptor = cryptor + self.wrapped = wrapped + super.init() + } + + override var streamStatus: Stream.Status { + return error != nil ? .error : wrapped.streamStatus + } + + override var streamError: Error? { + return error ?? wrapped.streamError + } + + override func schedule(in aRunLoop: RunLoop, forMode mode: RunLoop.Mode) { + wrapped.schedule(in: aRunLoop, forMode: mode) + } + + override func open() { + wrapped.open() + } + + override func close() { + try? encrypt() + wrapped.close() + } + + override func write(_ buffer: UnsafePointer, maxLength len: Int) -> Int { + if error != nil { + return -1 + } + + do { + if cleartextChunk.count == cryptor.cleartextChunkSize { + try encrypt() + } + + let taken = min(len, cryptor.cleartextChunkSize - cleartextChunk.count) + cleartextChunk.append(contentsOf: UnsafeBufferPointer(start: buffer, count: taken)) + + return taken + + } catch { + self.error = error + return -1 + } + } + + private func encrypt() throws { + if header == nil { + header = try cryptor.createHeader() + let ciphertextHeader = try cryptor.encryptHeader(header) + let result = wrapped.writeFully(ciphertextHeader, maxLength: ciphertextHeader.count) + if result != ciphertextHeader.count { + throw CryptoError.ioError + } + } + + let ciphertextChunk = try cryptor.encryptSingleChunk(cleartextChunk, + chunkNumber: chunkNumber, + headerNonce: header.nonce, + fileKey: header.contentKey) + cleartextChunk = [] + chunkNumber += 1 + + let result = wrapped.writeFully(ciphertextChunk, maxLength: ciphertextChunk.count) + if result != ciphertextChunk.count { + throw CryptoError.ioError + } + } +} diff --git a/Sources/CryptomatorCryptoLib/StreamTools.swift b/Sources/CryptomatorCryptoLib/StreamTools.swift new file mode 100644 index 0000000..4030e38 --- /dev/null +++ b/Sources/CryptomatorCryptoLib/StreamTools.swift @@ -0,0 +1,90 @@ +// +// StreamTools.swift +// +// Created by Julien Eyriès on 27/07/2022. +// Copyright © 2022 Julien Eyriès. All rights reserved. +// + +import Foundation + +enum StreamTools { + static func copyStream(inputStream: InputStream, outputStream: OutputStream) throws { + inputStream.schedule(in: .current, forMode: .default) + inputStream.open() + defer { inputStream.close() } + + outputStream.schedule(in: .current, forMode: .default) + outputStream.open() + defer { outputStream.close() } + + var buffer = [UInt8](repeating: 0x00, count: 4096) + + while true { + let readLength = inputStream.read(&buffer, maxLength: buffer.count) + if let error = inputStream.streamError { + throw error + } + if readLength <= 0 { + break + } + + let writeLength = outputStream.writeFully(buffer, maxLength: readLength) + if let error = outputStream.streamError { + throw error + } + if writeLength <= 0 { + break + } + } + } +} + +extension InputStream { + func readFully(_ buffer: UnsafeMutablePointer, maxLength len: Int) -> Int { + var offset = 0 + while offset < len { + let result = read(buffer + offset, maxLength: len - offset) + if result < 0 { + return result + } + if result == 0 { + return offset + } + offset += result + } + return len + } + + func readFullyIntoArray(maxLength len: Int) throws -> [UInt8] { + var buffer = [UInt8](repeating: 0, count: len) + let result = readFully(&buffer, maxLength: len) + if result < 0 { + throw streamError! + } + return Array(buffer.prefix(result)) + } +} + +extension OutputStream { + func writeFully(_ buffer: UnsafePointer, maxLength len: Int) -> Int { + var offset = 0 + while offset < len { + let result = write(buffer + offset, maxLength: len - offset) + if result < 0 { + return result + } + if result == 0 { + return -1 + } + offset += result + } + return len + } + + func writeFullyFromArray(_ array: [UInt8]) throws { + let result = writeFully(array, maxLength: array.count) + if result != array.count { + throw streamError! + } + } +} diff --git a/Tests/CryptomatorCryptoLibTests/CryptoStreamTests.swift b/Tests/CryptomatorCryptoLibTests/CryptoStreamTests.swift new file mode 100644 index 0000000..21b44cb --- /dev/null +++ b/Tests/CryptomatorCryptoLibTests/CryptoStreamTests.swift @@ -0,0 +1,72 @@ +// +// CryptoStreamTests.swift +// CryptomatorCryptoLibTests +// +// Created by Julien Eyriès on 26/07/2022. +// Copyright © 2022 Julien Eyriès. All rights reserved. +// + +import XCTest +@testable import CryptomatorCryptoLib + +class CryptoStreamTests: XCTestCase { + var contentCryptor: ContentCryptor! + var cryptor: Cryptor! + var tmpDirURL: URL! + + override class var defaultTestSuite: XCTestSuite { + // Return empty `XCTestSuite` so that no tests from this "abstract" `XCTestCase` is run. + // Make sure to override this in subclasses so that the implemented test case can run. + return XCTestSuite(name: "InterfaceTests Excluded") + } + + func setUpWithError(masterkey: Masterkey, cryptoSupport: CryptoSupport, contentCryptor: ContentCryptor) throws { + self.contentCryptor = contentCryptor + cryptor = Cryptor(masterkey: masterkey, cryptoSupport: cryptoSupport, contentCryptor: contentCryptor) + tmpDirURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tmpDirURL, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try FileManager.default.removeItem(at: tmpDirURL) + } + + func testEncryptAndDecryptStream() throws { + let cleartext = [UInt8]("hello world".data(using: .ascii)!) + + let encryptedStream = OutputStream.toMemory() + try StreamTools.copyStream(inputStream: InputStream(data: Data(cleartext)), + outputStream: cryptor.encryptOutputStream(wrapped: encryptedStream)) + + guard let encryptedData = encryptedStream.property(forKey: .dataWrittenToMemoryStreamKey) as? Data else { fatalError() } + + let decryptedStream = OutputStream.toMemory() + try StreamTools.copyStream(inputStream: cryptor.decryptInputStream(wrapped: InputStream(data: encryptedData)), + outputStream: decryptedStream) + + guard let decryptedData = decryptedStream.property(forKey: .dataWrittenToMemoryStreamKey) as? Data else { fatalError() } + + let decrypted = [UInt8](decryptedData) + XCTAssertEqual(cleartext, decrypted) + } + + func testEncryptAndDecryptContentStream() throws { + let originalData = Data(repeating: 0x0F, count: 65 * 1024) + let originalURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false) + try originalData.write(to: originalURL) + + let ciphertextURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false) + let cleartextURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false) + + // encrypt content + try StreamTools.copyStream(inputStream: InputStream(url: originalURL)!, + outputStream: cryptor.encryptOutputStream(wrapped: OutputStream(url: ciphertextURL, append: false)!)) + + // decrypt content + try StreamTools.copyStream(inputStream: cryptor.decryptInputStream(wrapped: InputStream(url: ciphertextURL)!), + outputStream: OutputStream(url: cleartextURL, append: false)!) + + let cleartextData = try Data(contentsOf: cleartextURL) + XCTAssertEqual(originalData, cleartextData) + } +}