TM-SGNL-iOS/SignalServiceKit/Cryptography/Cryptography.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1108 lines
48 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
import Foundation
import System
public enum Cryptography {
// SHA-256
/// Generates the SHA256 digest for a file.
public static func computeSHA256DigestOfFile(at url: URL) throws -> Data {
let file = try LocalFileHandle(url: url)
var sha256 = SHA256()
var buffer = Data(count: Constants.diskPageSize)
var bytesRead: Int
repeat {
bytesRead = try file.read(into: &buffer)
if bytesRead > 0 {
sha256.update(data: buffer.prefix(bytesRead))
}
} while bytesRead > 0
return Data(sha256.finalize())
}
}
// MARK: - Attachments
public struct EncryptionMetadata {
public let key: Data
public let digest: Data?
public let length: Int?
public let plaintextLength: Int?
public init(key: Data, digest: Data? = nil, length: Int? = nil, plaintextLength: Int? = nil) {
self.key = key
self.digest = digest
self.length = length
self.plaintextLength = plaintextLength
}
}
/// A read-only file handle to a file that is encrypted on disk but reads out plaintext bytes.
///
/// Functionally behaves like a FileHandle to a virtual plaintext file.
public protocol EncryptedFileHandle {
/// Length, in bytes, of the decrypted plaintext.
/// Comes from the sender; otherwise we don't know where content ends and custom padding begins.
var plaintextLength: UInt32 { get }
/// Gets the position of the file pointer within the virtual plaintext file.
func offset() -> UInt32
/// Moves the file pointer to the specified offset within the virtual plaintext file.
func seek(toOffset: UInt32) throws
/// Reads plaintext data synchronously, starting at the current offset, up to the specified number of bytes.
/// Returns empty data when the end of file is reached.
func read(upToCount: UInt32) throws -> Data
}
public extension Cryptography {
enum Constants {
static let hmac256KeyLength = 32
static let hmac256OutputLength = 32
static let aescbcIVLength = 16
static let aesKeySize = 32
static let aescbcBlockLength = 16
static var concatenatedEncryptionKeyLength: Int { aesKeySize + hmac256KeyLength }
/// Optimize reads/writes by reading this many bytes at once; best balance of performance/memory use from testing in practice.
static let diskPageSize = 8192
}
static func paddedSize(unpaddedSize: UInt) -> UInt {
// In order to obsfucate attachment size on the wire, we round up
// attachement plaintext bytes to the nearest power of 1.05. This
// number was selected as it provides a good balance between number
// of buckets and wasted bytes on the wire.
return UInt(max(541, floor(pow(1.05, ceil(log(Double(unpaddedSize)) / log(1.05))))))
}
static func randomAttachmentEncryptionKey() -> Data {
// The metadata "key" is actually a concatentation of the
// encryption key and the hmac key.
return Randomness.generateRandomBytes(UInt(Constants.concatenatedEncryptionKeyLength))
}
/// Encrypt an input file to a provided output file location.
/// The encrypted output is prefixed with the random iv and postfixed with the hmac. The ciphertext is padded
/// using standard pkcs7 padding but NOT with any custom padding applied to the plaintext prior to encryption.
///
/// - parameter unencryptedUrl: The file to encrypt.
/// - parameter encryptedUrl: Where to write the encrypted output file.
/// - parameter encryptionKey: The key to encrypt with; the AES key and the hmac key concatenated together.
/// (The same format as ``EncryptionMetadata/key``). A random key will be generated if none is provided.
static func encryptFile(
at unencryptedUrl: URL,
output encryptedUrl: URL,
encryptionKey inputKey: Data? = nil
) throws -> EncryptionMetadata {
return try _encryptFile(
at: unencryptedUrl,
output: encryptedUrl,
encryptionKey: inputKey,
applyExtraPadding: false
)
}
/// Encrypt an input file to a provided output file location.
/// The encrypted output is prefixed with the random iv and postfixed with the hmac. The ciphertext is padded
/// using standard pkcs7 padding AND with custom bucketing padding applied to the plaintext prior to encryption.
///
/// - parameter unencryptedUrl: The file to encrypt.
/// - parameter encryptedUrl: Where to write the encrypted output file.
/// - parameter encryptionKey: The key to encrypt with; the AES key and the hmac key concatenated together.
/// (The same format as ``EncryptionMetadata/key``). A random key will be generated if none is provided.
static func encryptAttachment(
at unencryptedUrl: URL,
output encryptedUrl: URL,
encryptionKey inputKey: Data? = nil
) throws -> EncryptionMetadata {
return try _encryptFile(
at: unencryptedUrl,
output: encryptedUrl,
encryptionKey: inputKey,
applyExtraPadding: true
)
}
static func _encryptFile(
at unencryptedUrl: URL,
output encryptedUrl: URL,
encryptionKey inputKey: Data?,
applyExtraPadding: Bool
) throws -> EncryptionMetadata {
if let inputKey, inputKey.count != Constants.concatenatedEncryptionKeyLength {
throw OWSAssertionError("Invalid encryption key length")
}
guard FileManager.default.fileExists(atPath: unencryptedUrl.path) else {
throw OWSAssertionError("Missing attachment file.")
}
let inputFile = try LocalFileHandle(url: unencryptedUrl)
guard FileManager.default.createFile(
atPath: encryptedUrl.path,
contents: nil,
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
) else {
throw OWSAssertionError("Cannot access output file.")
}
let outputFile = try FileHandle(forWritingTo: encryptedUrl)
let inputKey = inputKey ?? randomAttachmentEncryptionKey()
let encryptionKey = inputKey.prefix(Constants.aesKeySize)
let hmacKey = inputKey.suffix(Constants.hmac256KeyLength)
return try _encryptAttachment(
enumerateInputInBlocks: { closure in
var buffer = Data(count: Constants.diskPageSize)
var totalBytesRead: UInt = 0
var bytesRead: Int
repeat {
bytesRead = try inputFile.read(into: &buffer)
if bytesRead > 0 {
totalBytesRead += UInt(bytesRead)
try closure(buffer.prefix(bytesRead))
}
} while bytesRead > 0
return totalBytesRead
},
output: { outputBlock in
outputFile.write(outputBlock)
},
encryptionKey: encryptionKey,
hmacKey: hmacKey,
applyExtraPadding: applyExtraPadding
)
}
/// Re-encrypt the contents of an encrypted file handle source and return the result in a new file.
/// The encrypted output is prefixed with the random iv and postfixed with the hmac.
/// The ciphertext is padded using standard pkcs7 padding AND allows for applying optional custom bucketing padding
/// to the plaintext prior to encryption.
///
/// - parameter encryptedFileHandle: The encrypted file handle to read from.
/// - parameter encryptionKey: The key to encrypt with; the AES key and the hmac key concatenated together.
/// (The same format as ``EncryptionMetadata/key``). A random key will be generated if none is provided.
/// - parameter encryptedOutputUrl: Where to write the reencrypted output.
/// - parameter applyExtraPadding: If true, extra zero padding will be applied to ensure bucketing of file sizes,
/// in addition to standard PKCS7 padding. If false, only standard PKCS7 padding is applied.
static func reencryptFileHandle(
at encryptedFileHandle: EncryptedFileHandle,
encryptionKey inputKey: Data?,
encryptedOutputUrl outputFileURL: URL,
applyExtraPadding: Bool
) throws -> EncryptionMetadata {
guard FileManager.default.createFile(
atPath: outputFileURL.path,
contents: nil,
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
) else {
throw OWSAssertionError("Cannot access output file.")
}
let outputFileHandle = try FileHandle(forWritingTo: outputFileURL)
let inputKey = inputKey ?? randomAttachmentEncryptionKey()
let encryptionKey = inputKey.prefix(Constants.aesKeySize)
let hmacKey = inputKey.suffix(Constants.hmac256KeyLength)
return try _encryptAttachment(
enumerateInputInBlocks: { closure in
var totalBytesRead: UInt = 0
var bytesRead: Int
repeat {
let data = try encryptedFileHandle.read(upToCount: UInt32(Constants.diskPageSize))
bytesRead = data.count
if bytesRead > 0 {
totalBytesRead += UInt(bytesRead)
try closure(data)
}
} while bytesRead > 0
return totalBytesRead
},
output: { outputBlock in
outputFileHandle.write(outputBlock)
},
encryptionKey: encryptionKey,
hmacKey: hmacKey,
applyExtraPadding: applyExtraPadding
)
}
/// Encrypt input data in memory, producing the encrypted output data.
///
/// - parameter input: The data to encrypt.
/// - parameter encryptionKey: The key to encrypt with; the AES key and the hmac key concatenated together.
/// (The same format as ``EncryptionMetadata/key``). A random key will be generated if none is provided.
/// - parameter iv: the iv to use. If nil, a random iv is generated. If provided, but be of length ``Cryptography/aescbcIVLength``.
/// - parameter applyExtraPadding: If true, extra zero padding will be applied to ensure bucketing of file sizes,
/// in addition to standard PKCS7 padding. If false, only standard PKCS7 padding is applied.
///
/// - returns: The encrypted padded data prefixed with the random iv and postfixed with the hmac.
static func encrypt(
_ input: Data,
encryptionKey inputKey: Data? = nil,
iv: Data? = nil,
applyExtraPadding: Bool = false
) throws -> (Data, EncryptionMetadata) {
if let inputKey, inputKey.count != Constants.concatenatedEncryptionKeyLength {
throw OWSAssertionError("Invalid encryption key length")
}
let inputKey = inputKey ?? randomAttachmentEncryptionKey()
let encryptionKey = inputKey.prefix(Constants.aesKeySize)
let hmacKey = inputKey.suffix(Constants.hmac256KeyLength)
var outputData = Data()
let encryptionMetadata = try _encryptAttachment(
enumerateInputInBlocks: { closure in
// Just run the whole input at once; its already in memory.
try closure(input)
return UInt(input.count)
},
output: { outputBlock in
outputData.append(outputBlock)
},
encryptionKey: encryptionKey,
hmacKey: hmacKey,
iv: iv,
applyExtraPadding: applyExtraPadding
)
return (outputData, encryptionMetadata)
}
/// Encrypt an attachment source to an output sink.
///
/// - parameter enumerateInputInBlocks: The caller should enumerate blocks of the plaintext
/// input one at a time (size up to the caller) until the entire input has been provided, and then return the
/// byte length of the plaintext input.
/// - parameter output: Called by this method with each chunk of output ciphertext data.
/// - parameter encryptionKey: The key used for encryption. Must be of byte length ``Cryptography/aesKeySize``.
/// - parameter hmacKey: The key used for hmac. Must be of byte length ``Cryptography/hmac256KeyLength``.
/// - parameter iv: the iv to use. If nil, a random iv is generated. If provided, but be of length ``Cryptography/aescbcIVLength``.
/// - parameter applyExtraPadding: If true, additional padding is applied _before_ pkcs7 padding to obfuscate
/// the size of the encrypted file. If false, only standard pkcs7 padding is used.
private static func _encryptAttachment(
// Run the closure on blocks of the input until complete and then return input plaintext length.
enumerateInputInBlocks: ((Data) throws -> Void) throws -> UInt,
output: @escaping (Data) -> Void,
encryptionKey: Data,
hmacKey: Data,
iv inputIV: Data? = nil,
applyExtraPadding: Bool
) throws -> EncryptionMetadata {
var totalOutputOffset: Int = 0
let output: (Data) -> Void = { outputData in
totalOutputOffset += outputData.count
output(outputData)
}
let iv: Data
if let inputIV {
if inputIV.count != Constants.aescbcIVLength {
throw OWSAssertionError("Invalid IV length")
}
iv = inputIV
} else {
iv = Randomness.generateRandomBytes(UInt(Constants.aescbcIVLength))
}
var hmac = HMAC<SHA256>(key: .init(data: hmacKey))
var sha256 = SHA256()
let cipherContext = try CipherContext(
operation: .encrypt,
algorithm: .aes,
options: .pkcs7Padding,
key: encryptionKey,
iv: iv
)
// We include our IV at the start of the file *and*
// in both the hmac and digest.
hmac.update(data: iv)
sha256.update(data: iv)
output(iv)
let unpaddedPlaintextLength: UInt
// Encrypt the file by enumerating blocks. We want to keep our
// memory footprint as small as possible during encryption.
do {
unpaddedPlaintextLength = try enumerateInputInBlocks { plaintextDataBlock in
let ciphertextBlock = try cipherContext.update(plaintextDataBlock)
hmac.update(data: ciphertextBlock)
sha256.update(data: ciphertextBlock)
output(ciphertextBlock)
}
// Add zero padding to the plaintext attachment data if necessary.
let paddedPlaintextLength = paddedSize(unpaddedSize: unpaddedPlaintextLength)
if applyExtraPadding, paddedPlaintextLength > unpaddedPlaintextLength {
let ciphertextBlock = try cipherContext.update(
Data(repeating: 0, count: Int(paddedPlaintextLength - unpaddedPlaintextLength))
)
hmac.update(data: ciphertextBlock)
sha256.update(data: ciphertextBlock)
output(ciphertextBlock)
}
// Finalize the encryption and write out the last block.
// Every time we "update" the cipher context, it returns
// the ciphertext for the previous block so there will
// always be one block remaining when we "finalize".
let finalCiphertextBlock = try cipherContext.finalize()
hmac.update(data: finalCiphertextBlock)
sha256.update(data: finalCiphertextBlock)
output(finalCiphertextBlock)
}
// Calculate our HMAC. This will be used to verify the
// data after decryption.
// hmac of: iv || encrypted data
let hmacResult = Data(hmac.finalize())
// We write the hmac at the end of the file for the
// receiver to use for verification. We also include
// it in the digest.
sha256.update(data: hmacResult)
output(hmacResult)
// Calculate our digest. This will be used to verify
// the data after decryption.
// digest of: iv || encrypted data || hmac
let digest = Data(sha256.finalize())
return EncryptionMetadata(
key: encryptionKey + hmacKey,
digest: digest,
length: totalOutputOffset,
plaintextLength: Int(unpaddedPlaintextLength)
)
}
static func decryptAttachment(
at encryptedUrl: URL,
metadata: EncryptionMetadata,
output unencryptedUrl: URL
) throws {
// We require digests for all attachments.
guard let digest = metadata.digest, !digest.isEmpty else {
throw OWSAssertionError("Missing digest")
}
try decryptFile(at: encryptedUrl, metadata: metadata, output: unencryptedUrl)
}
static func decryptAttachment(
at encryptedUrl: URL,
metadata: EncryptionMetadata
) throws -> Data {
// We require digests for all attachments.
guard let digest = metadata.digest, !digest.isEmpty else {
throw OWSAssertionError("Missing digest")
}
return try decryptFile(at: encryptedUrl, metadata: metadata)
}
static func validateAttachment(
at encryptedUrl: URL,
metadata: EncryptionMetadata
) -> Bool {
// We require digests for all attachments.
guard let digest = metadata.digest, !digest.isEmpty else {
owsFailDebug("Missing digest")
return false
}
return validateFile(at: encryptedUrl, metadata: metadata)
}
static func encryptedAttachmentFileHandle(
at encryptedUrl: URL,
plaintextLength: UInt32,
encryptionKey: Data
) throws -> EncryptedFileHandle {
return try EncryptedFileHandleImpl(
encryptedUrl: encryptedUrl,
paddingDecryptionStrategy: .customPadding(plaintextLength: plaintextLength),
encryptionKey: encryptionKey
)
}
static func encryptedFileHandle(
at encryptedUrl: URL,
encryptionKey: Data
) throws -> EncryptedFileHandle {
return try EncryptedFileHandleImpl(
encryptedUrl: encryptedUrl,
paddingDecryptionStrategy: .pkcs7Only,
encryptionKey: encryptionKey
)
}
static func decryptFile(
at encryptedUrl: URL,
metadata: EncryptionMetadata,
output unencryptedUrl: URL
) throws {
guard FileManager.default.createFile(
atPath: unencryptedUrl.path,
contents: nil,
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
) else {
throw OWSAssertionError("Cannot access output file.")
}
let outputFile = try FileHandle(forWritingTo: unencryptedUrl)
do {
try decryptFile(
at: encryptedUrl,
metadata: metadata,
// Most efficient to write one page size at a time.
outputBlockSize: UInt32(Constants.diskPageSize)
) { plaintextDataBlock in
outputFile.write(plaintextDataBlock)
}
} catch let error {
// In the event of any failure, we both throw *and*
// delete the partially decrypted output file.
outputFile.closeFile()
do {
try FileManager.default.removeItem(at: unencryptedUrl)
} catch let fileDeletionError {
Logger.error("Failed to clean up file after cryptography failure: \(fileDeletionError)")
}
throw error
}
}
/// Decrypt a file to an output file without validating the hmac or digest (even if the digest is provided in `metadata`).
static func decryptFileWithoutValidating(
at encryptedUrl: URL,
metadata: EncryptionMetadata,
output unencryptedUrl: URL
) throws {
guard FileManager.default.createFile(
atPath: unencryptedUrl.path,
contents: nil,
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
) else {
throw OWSAssertionError("Cannot access output file.")
}
let outputFile = try FileHandle(forWritingTo: unencryptedUrl)
do {
try decryptFile(
at: encryptedUrl,
metadata: metadata,
validateHmacAndDigest: false,
// Most efficient to write one page size at a time.
outputBlockSize: UInt32(Constants.diskPageSize)
) { plaintextDataBlock in
outputFile.write(plaintextDataBlock)
}
} catch let error {
// In the event of any failure, we both throw *and*
// delete the partially decrypted output file.
outputFile.closeFile()
try FileManager.default.removeItem(at: unencryptedUrl)
throw error
}
}
static func decryptFile(
at encryptedUrl: URL,
metadata: EncryptionMetadata
) throws -> Data {
var plaintext = Data()
try decryptFile(
at: encryptedUrl,
metadata: metadata,
// Read the whole thing into memory.
outputBlockSize: nil
) { plaintextDataBlock in
plaintext = plaintextDataBlock
}
return plaintext
}
/// Decrypt a file to a in memory data without validating the hmac or digest (even if the digest is provided in `metadata`).
static func decryptFileWithoutValidating(
at encryptedUrl: URL,
metadata: EncryptionMetadata
) throws -> Data {
var plaintext = Data()
try decryptFile(
at: encryptedUrl,
metadata: metadata,
validateHmacAndDigest: false,
// Read the whole thing into memory.
outputBlockSize: nil
) { plaintextDataBlock in
plaintext = plaintextDataBlock
}
return plaintext
}
static func validateFile(
at encryptedUrl: URL,
metadata: EncryptionMetadata
) -> Bool {
do {
// Don't do anything with the bytes, just read to the end to validate.
try decryptFile(at: encryptedUrl, metadata: metadata) { _ in }
return true
} catch {
return false
}
}
/// - parameter validateHmacAndDigest: If true, the source file is assumed to have a computed hmac
/// at the end, which will be validated against the live-computed hmac. Likewise, a live-computed digest
/// will be validated against the digest in the provided metadata.
/// - parameter outputBlockSize: Maximum number of bytes that will be read into memory at once
/// and emitted in a single call to `output`. If nil, the length of the file is the limit. Defaults to 16kb.
static func decryptFile(
at encryptedUrl: URL,
metadata: EncryptionMetadata,
validateHmacAndDigest: Bool = true,
outputBlockSize: UInt32? = 1024 * 16,
output: (_ plaintextDataBlock: Data) -> Void
) throws {
let paddingStrategy: PaddingDecryptionStrategy
if let plaintextLength = metadata.plaintextLength {
paddingStrategy = .customPadding(plaintextLength: UInt32(plaintextLength))
} else {
paddingStrategy = .pkcs7Only
}
let inputFile = try EncryptedFileHandleImpl(
encryptedUrl: encryptedUrl,
paddingDecryptionStrategy: paddingStrategy,
encryptionKey: metadata.key
)
var hmac: HMAC<SHA256>?
var sha256: SHA256?
if validateHmacAndDigest {
// The metadata "key" is actually a concatentation of the
// encryption key and the hmac key.
let hmacKey = metadata.key.suffix(Constants.hmac256KeyLength)
hmac = HMAC<SHA256>(key: .init(data: hmacKey))
if metadata.digest != nil {
sha256 = SHA256()
}
// Matching encryption, we must start our hmac
// and digest with the IV, since the encrypted
// file starts with the IV
hmac?.update(data: inputFile.iv)
sha256?.update(data: inputFile.iv)
}
var totalPlaintextLength = 0
// Decrypt the file by enumerating blocks. We want to keep our
// memory footprint as small as possible during decryption.
var gotEmptyBlock = false
repeat {
let plaintextDataBlock = try inputFile.readInternal(
upToCount: outputBlockSize ?? inputFile.plaintextLength
) { ciphertext, ciphertextLength in
hmac?.update(data: ciphertext.prefix(ciphertextLength))
sha256?.update(data: ciphertext.prefix(ciphertextLength))
}
if plaintextDataBlock.isEmpty {
gotEmptyBlock = true
} else {
output(plaintextDataBlock)
totalPlaintextLength += plaintextDataBlock.count
}
} while !gotEmptyBlock
// If a plaintext length was specified, validate that we actually
// received plaintext of that length. Note, some older clients do
// not tell us about the unpadded plaintext length so we cannot
// universally check this.
switch paddingStrategy {
case .customPadding(let plaintextLength) where plaintextLength != totalPlaintextLength:
throw OWSAssertionError("Incorrect plaintext length.")
default:
break
}
if validateHmacAndDigest, var hmac {
// Add the last padding bytes to the hmac/digest.
var remainingPaddingLength = Constants.aescbcIVLength + inputFile.ciphertextLength - inputFile.file.offsetInFile
while remainingPaddingLength > 0 {
let lengthToRead = min(remainingPaddingLength, 1024 * 16)
let paddingCiphertext = try inputFile.file.readData(ofLength: Int(lengthToRead))
hmac.update(data: paddingCiphertext)
sha256?.update(data: paddingCiphertext)
remainingPaddingLength -= lengthToRead
}
// Verify their HMAC matches our locally calculated HMAC
// hmac of: iv || encrypted data
let hmacResult = Data(hmac.finalize())
// The last N bytes of the encrypted file is the hmac for the encrypted data.
// At this point we are done with the EncryptedFileHandle, so grab its internal
// FileHandle for reading directly.
// (This breaks EncryptedFileHandle's invariants and renders it unuseable).
let inputFileHmac = try inputFile.file.readData(ofLength: Constants.hmac256OutputLength)
guard hmacResult.ows_constantTimeIsEqual(to: inputFileHmac) else {
Logger.debug("Bad hmac. Their hmac: \(inputFileHmac.hexadecimalString), our hmac: \(hmacResult.hexadecimalString)")
throw OWSAssertionError("Bad hmac")
}
// Verify their digest matches our locally calculated digest
// digest of: iv || encrypted data || hmac
if let theirDigest = metadata.digest {
guard var sha256 else {
throw OWSAssertionError("Missing digest context")
}
sha256.update(data: hmacResult)
let digest = Data(sha256.finalize())
guard digest.ows_constantTimeIsEqual(to: theirDigest) else {
Logger.debug("Bad digest. Their digest: \(theirDigest.hexadecimalString), our digest: \(digest.hexadecimalString)")
throw OWSAssertionError("Bad digest")
}
}
}
}
private enum PaddingDecryptionStrategy {
/// The file was encrypted with PKCS7 padding _and_ added custom padding.
/// (Typically the custom padding is all 0s, though this should not be assumed.)
///
/// The sender necessarily provided the plaintext length before pkcs7; the presence of
/// this value is what tells us to expect the additional padding and tells us where it is.
case customPadding(plaintextLength: UInt32)
/// The file uses standard PKCS7 padding _only_.
///
/// The sender did not provide a plaintext length and thus we can only assume trailing
/// bytes are part of the plaintext.
case pkcs7Only
}
/// Internal implementation of EncryptedFileHandle exposing some internals
/// for cryptographic verification as we read things out.
private class EncryptedFileHandleImpl: EncryptedFileHandle {
fileprivate let file: LocalFileHandle
private let encryptionKey: Data
fileprivate let iv: Data
/// In short: did the sender include custom padding and a plaintext data length,
/// or will we assume only pkcs7 padding is used?
fileprivate let paddingDecryptionStrategy: PaddingDecryptionStrategy
/// Plaintext length excluding padding.
/// We truncate everything after this length in the final output.
/// Either the sender gives this to us directly, or we assume only pkcs7 padding
/// is used and compute this length using that assumption.
private let _plaintextLength: Int
public var plaintextLength: UInt32 { UInt32(_plaintextLength) }
/// Excluding iv and hmac, including padding
fileprivate let ciphertextLength: Int
private var virtualOffset: Int = 0
private var cipherContext: CipherContext
/// Buffers the output of the cipherContext if the last read requested fewer bytes than the cipherContext output.
/// CCCryptor documentation says: "the output length is never larger than the input length plus the block size."
/// To ensure we always have enough room in the buffer, we allocate two block lengths.
/// `numBytesInPlaintextBuffer` indicates how many bytes (starting from 0) contain non-stale content.
private var plaintextBuffer = Data(repeating: 0, count: Constants.aescbcBlockLength * 2)
private var numBytesInPlaintextBuffer = 0
init(
encryptedUrl: URL,
paddingDecryptionStrategy: PaddingDecryptionStrategy,
encryptionKey: Data
) throws {
guard FileManager.default.fileExists(atPath: encryptedUrl.path) else {
throw OWSAssertionError("Missing attachment file.")
}
guard encryptionKey.count == (Constants.aesKeySize + Constants.hmac256KeyLength) else {
throw OWSAssertionError("Encryption key shorter than combined key length")
}
self.file = try LocalFileHandle(url: encryptedUrl)
let cryptoOverheadLength = Constants.aescbcIVLength + Constants.hmac256OutputLength
self.ciphertextLength = file.fileLength - cryptoOverheadLength
// The metadata "key" is actually a concatentation of the
// encryption key and the hmac key.
self.encryptionKey = encryptionKey.prefix(Constants.aesKeySize)
// This first N bytes of the encrypted file are the IV
self.iv = try file.readData(ofLength: Constants.aescbcIVLength)
guard iv.count == Constants.aescbcIVLength else {
throw OWSAssertionError("Failed to read IV")
}
self.paddingDecryptionStrategy = paddingDecryptionStrategy
switch paddingDecryptionStrategy {
case .customPadding(let plaintextLength):
// The sender gave us the expected length; easy option.
// We truncate everything after this length in the final output.
self._plaintextLength = Int(plaintextLength)
case .pkcs7Only:
// We want to read the last two blocks before the hmac so we can
// determine the pkcs7 padding length.
let prePaddingBlockOffset = file.fileLength
// Not the hmac
- Constants.hmac256OutputLength
// Start of the previous block which has the pkcs7 padding
- Constants.aescbcBlockLength
// Start of the block before that which has its iv
- Constants.aescbcBlockLength
try file.seek(toFileOffset: prePaddingBlockOffset)
// Read the preceding block, use it as the IV.
let paddingBlockIV = try file.readData(ofLength: Constants.aescbcBlockLength)
// Read the block itself
let paddingBlockCiphertext = try file.readData(ofLength: Constants.aescbcBlockLength)
// Decrypt, but use ecb instead of cbc mode; we _want_ the plaintext
// of the pkcs7 padding bytes; doing the block cipher XOR'ing ourselves
// lets us get that without the CipherContext truncating it for us.
let paddingCipherContext = try CipherContext(
operation: .decrypt,
algorithm: .aes,
options: .ecbMode,
key: self.encryptionKey,
// Irrelevant in ecb mode.
iv: Data(repeating: 0, count: Constants.aescbcBlockLength)
)
var paddingBlockPlaintext = try paddingCipherContext.update(paddingBlockCiphertext)
paddingBlockPlaintext.append(try paddingCipherContext.finalize())
// Grab the last byte of the last ciphertext block; this is the pkcs7
// padding (which needs to be XORd with the equivalent byte in the iv.
var paddingByte = paddingBlockPlaintext[paddingBlockPlaintext.count - 1]
// Bitwise XOR it with the previous block's last byte
paddingByte = paddingByte ^ paddingBlockIV[paddingBlockIV.count - 1]
// Each byte of padding is itself the length of the padding.
let paddingLength = paddingByte
self._plaintextLength = ciphertextLength - Int(paddingLength)
// Move the file handle to the start of the encrypted data (after IV)
try file.seek(toFileOffset: Constants.aescbcIVLength)
}
// We should be just after the iv at this point.
owsAssertDebug(file.offsetInFile == Constants.aescbcIVLength)
self.cipherContext = try CipherContext(
operation: .decrypt,
algorithm: .aes,
options: .pkcs7Padding,
key: self.encryptionKey,
iv: iv
)
}
// MARK: - API
func offset() -> UInt32 {
return UInt32(virtualOffset)
}
func seek(toOffset: UInt32) throws {
let toOffset = Int(toOffset)
guard toOffset <= _plaintextLength else {
throw OWSAssertionError("Seeking past end of file")
}
// No need to modify the bytes in the buffer; just mark them as stale.
numBytesInPlaintextBuffer = 0
// The offset in the encrypted file rounds down to the start of the block.
// Add 1 because the first block in the encrypted file is the iv which isn't
// represented in the virtual plaintext's address space.
var (desiredBlock, desiredOffsetInBlock) = toOffset.quotientAndRemainder(dividingBy: Constants.aescbcBlockLength)
desiredBlock += 1
// The preceding block serves as the iv for decryption.
let ivBlock = desiredBlock - 1
let ivOffset = ivBlock * Constants.aescbcBlockLength
try file.seek(toFileOffset: ivOffset)
let iv = try file.readData(ofLength: Constants.aescbcBlockLength)
// Initialize a new context with the preceding block as the iv.
self.cipherContext = try CipherContext(
operation: .decrypt,
algorithm: .aes,
options: .pkcs7Padding,
key: encryptionKey,
iv: iv
)
// Set our virtual offset to the start of the target block.
// Then read and discard bytes up to the desired offset within the block.
// This ensures the cipherContext is properly caught up to the target offset.
//
// For example, say the target offset is 18. We use the first 16 bytes as iv, and
// then the next two bytes need to be read into the cipherContext so that the next
// bytes are decrypted properly, but we don't actually want to ouput them. So we read
// those 2 bytes normally (which updates virtualOffset), and discard them. Typically,
// this means the read method reads the next block (bytes 16-31), decrypts, returns
// the first 2, puts the rest into plaintextBuffer, and increments virtualOffset by 2.
self.virtualOffset = toOffset - desiredOffsetInBlock
if desiredOffsetInBlock > 0 {
_ = try self.read(upToCount: UInt32(desiredOffsetInBlock))
}
}
func read(upToCount: UInt32) throws -> Data {
return try readInternal(upToCount: upToCount)
}
fileprivate func readInternal(
upToCount requestedByteCount: UInt32,
// We run this on every block of ciphertext we read.
ciphertextHandler: ((_ ciphertext: Data, _ ciphertextLength: Int) throws -> Void)? = nil
) throws -> Data {
guard requestedByteCount < Int.max else {
throw OWSAssertionError("Requesting too much data at once")
}
// To callers, "virtualOffset" is the offset and "plaintextLength" is the file
// length (because we pretend this is the decrypted file). If a caller asks
// for more bytes after what should be the end, give them back empty bytes.
guard virtualOffset < plaintextLength else {
return Data()
}
// Don't try and read past the end of the file.
let totalBytesInOutput = min(Int(requestedByteCount), _plaintextLength - virtualOffset)
// Allocate memory up front.
var outputBuffer = Data(repeating: 0, count: totalBytesInOutput)
// Start tracking how many bytes we have written.
var bytesWrittenToOutput = 0
defer { self.virtualOffset += bytesWrittenToOutput }
// If we have data in the plaintext buffer, use that first.
if numBytesInPlaintextBuffer > 0 {
let numBytesToReadOffPlaintextBuffer = min(totalBytesInOutput, numBytesInPlaintextBuffer)
outputBuffer[0..<numBytesToReadOffPlaintextBuffer] = plaintextBuffer[0..<numBytesToReadOffPlaintextBuffer]
// Shift the remaining bytes forward in the buffer so they start at 0
if numBytesToReadOffPlaintextBuffer < numBytesInPlaintextBuffer {
plaintextBuffer[0..<(numBytesInPlaintextBuffer - numBytesToReadOffPlaintextBuffer)] =
plaintextBuffer[numBytesToReadOffPlaintextBuffer..<numBytesInPlaintextBuffer]
}
numBytesInPlaintextBuffer -= numBytesToReadOffPlaintextBuffer
bytesWrittenToOutput += numBytesToReadOffPlaintextBuffer
}
// If we got all the bytes we needed from the plaintext buffer, we are done.
if bytesWrittenToOutput == totalBytesInOutput {
return outputBuffer
}
func computeNumCiphertextBytesToRead() -> Int {
var result = totalBytesInOutput - bytesWrittenToOutput
// Round up to the nearest block length.
let (remainder, _) = result.remainderReportingOverflow(dividingBy: 16)
if remainder != 0 {
result += 16 - remainder
}
// Read at most the page size; no point in reading more.
result = min(result, Constants.diskPageSize)
// But never read past the end of the file.
result = min(result, Constants.aescbcIVLength + ciphertextLength - file.offsetInFile)
return result
}
var numCiphertextBytesToRead = computeNumCiphertextBytesToRead()
// The first chunk size is the biggest we will ever get; allocate a buffer
// for that size and further chunks we read can reuse the same buffer.
var ciphertextBuffer = Data(repeating: 0, count: numCiphertextBytesToRead)
while bytesWrittenToOutput < totalBytesInOutput {
let emptyBytesInOutput = totalBytesInOutput - bytesWrittenToOutput
defer { numCiphertextBytesToRead = computeNumCiphertextBytesToRead() }
let expectedPlaintextLength: Int
if numCiphertextBytesToRead == 0 {
// If we are at the end of the file, we want to finalize.
expectedPlaintextLength = try cipherContext.outputLengthForFinalize()
} else {
expectedPlaintextLength = try cipherContext.outputLength(
forUpdateWithInputLength: numCiphertextBytesToRead
)
}
// We need to reference either `outputBuffer` or a tmp buffer, depending
// on state. This must be done by an inout parameter, if we e.g. did
// var someVar = outputBuffer
// someVar.updateSomeBytes(...)
// Then someVar points to a _copy_ of outputBuffer and changes aren't
// reflected back onto outputBuffer without an expensive copy operation.
let writeToBuffer: (_ block: (inout Data, _ offset: Int) throws -> Int) throws -> Int
let didWriteDirectlyToOutput: Bool
if expectedPlaintextLength <= emptyBytesInOutput {
// If we are reading as many bytes than we need or less,
// just write directly into the output buffer.
// Offset by num bytes written so far so we "append" into the reserved space.
writeToBuffer = {
return try $0(&outputBuffer, bytesWrittenToOutput)
}
didWriteDirectlyToOutput = true
} else {
// Otherwise this is the final loop because we are reading more
// bytes than we need. Read into a new buffer instead so that we
// can divvy up the bytes between output and our plaintext buffer.
var tmpBuffer = Data(repeating: 0, count: expectedPlaintextLength)
writeToBuffer = {
// No offset; just write straight into the tmp buffer.
return try $0(&tmpBuffer, 0)
}
didWriteDirectlyToOutput = false
}
let actualPlaintextLength: Int
if numCiphertextBytesToRead == 0 {
// If we are at the end of the file, finalize the cipher context
// instead of reading from disk and updating.
actualPlaintextLength = try writeToBuffer {
return try cipherContext.finalize(
output: &$0,
offsetInOutput: $1,
outputLength: expectedPlaintextLength
)
}
} else {
// Otherwise we aren't at the end of the file, read and update.
let ciphertextLength = try file.read(
into: &ciphertextBuffer,
maxLength: numCiphertextBytesToRead
)
if ciphertextLength < numCiphertextBytesToRead {
// We are careful to not request bytes past the end of the file;
// if we read fewer bytes than requested it must be an error.
throw OWSAssertionError("Failed to read file")
}
try ciphertextHandler?(ciphertextBuffer, ciphertextLength)
actualPlaintextLength = try writeToBuffer {
return try cipherContext.update(
input: ciphertextBuffer,
inputLength: ciphertextLength,
output: &$0,
offsetInOutput: $1,
outputLength: expectedPlaintextLength
)
}
}
if didWriteDirectlyToOutput {
bytesWrittenToOutput += actualPlaintextLength
} else {
let numBytesToCopyToOutput = min(actualPlaintextLength, emptyBytesInOutput)
let numBytesToCopyToPlaintextBuffer = actualPlaintextLength - numBytesToCopyToOutput
_ = try writeToBuffer { tmpBuffer, _ in
// Copy bytes to the output buffer up to what we need.
outputBuffer[bytesWrittenToOutput..<(bytesWrittenToOutput + numBytesToCopyToOutput)] =
tmpBuffer[0..<numBytesToCopyToOutput]
// Copy the rest into the plaintext buffer.
if numBytesToCopyToPlaintextBuffer > 0 {
self.plaintextBuffer[0..<numBytesToCopyToPlaintextBuffer] =
tmpBuffer[numBytesToCopyToOutput..<actualPlaintextLength]
}
self.numBytesInPlaintextBuffer += numBytesToCopyToPlaintextBuffer
return 0 /* return value irrelevant */
}
bytesWrittenToOutput += numBytesToCopyToOutput
}
// Defensive check; don't read past end of file.
if numCiphertextBytesToRead == 0 {
break
}
}
return outputBuffer
}
}
}
// MARK: - Direct file access
/// A convenience wrapper around a read-only file.
private class LocalFileHandle {
private let fileDescriptor: FileDescriptor
/// Determined at open time and assumed to be fixed.
let fileLength: Int
/// The current offset from the start of the file measured in bytes. Measured with `lseek`.
var offsetInFile: Int { Int((try? fileDescriptor.seek(offset: 0, from: .current)) ?? 0) }
init(url: URL) throws {
guard let filePath = FilePath(url) else {
throw OWSAssertionError("Provided url \(url) is not a file path")
}
guard
let fileLength = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize
else {
throw OWSAssertionError("Unable to read file length")
}
self.fileDescriptor = try FileDescriptor.open(filePath, .readOnly)
self.fileLength = fileLength
}
/// Read up to a number bytes into the provided buffer.
///
/// - parameter buffer: Output is written into here.
/// - parameter maxLength: Maximum number of bytes to read into `buffer`.
/// If nil, the length of the buffer is used.
/// Warning: Using a value greater than buffer length will get capped by buffer length.
///
/// - returns: The actual number of bytes read. Zero indicates the end of the file has been reached.
///
/// - throws: if an error occurs
func read(into buffer: inout Data, maxLength: Int? = nil) throws -> Int {
try buffer.withUnsafeMutableBytes {
try fileDescriptor.read(into: UnsafeMutableRawBufferPointer(rebasing: $0.prefix(maxLength ?? $0.count)))
}
}
/// Convenience wrapper around ``read(into:maxLength:)`` that returns the output
/// as bytes and assumes any failure to read the requested number of bytes is an error.
///
/// Notably, calling this method with a length that would go past the end of the file
/// will throw an error.
func readData(ofLength length: Int) throws -> Data {
var buffer = Data(count: length)
let numBytesRead = try read(into: &buffer)
guard numBytesRead == length else {
throw OWSAssertionError("Unable to read data")
}
return buffer
}
/// Seek to a desired offset in the file, defined relative to the beginning of the file.
func seek(toFileOffset desiredOffset: Int) throws {
try fileDescriptor.seek(offset: Int64(desiredOffset), from: .start)
}
deinit {
try! fileDescriptor.close()
}
}