Path: blob/main/SignalServiceKit/Attachments/PaddingBucket.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CommonCrypto
import Foundation
/// Computes (and un-computes) attachment padding.
///
/// In order to obsfucate attachment size on the wire, we round up
/// attachment 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.
///
/// This type can compute that padding, and it can also "reverse" the
/// process and determine the maximum plaintext size that will fit within a
/// particular encrypted size limit.
struct PaddingBucket {
private enum Constants {
static let paddingMultiplier = 1.05
static let smallestBucketNumber: Int = 129 // => 541 bytes
static let ivLength = UInt64(Cryptography.Constants.aescbcIVLength)
static let hmacLength = UInt64(Cryptography.Constants.hmac256OutputLength)
static let blockLength = UInt64(kCCBlockSizeAES128)
}
let bucketNumber: Int
/// The plaintext size with padding.
let plaintextSize: UInt64
/// The encrypted size with padding & encryption overhead.
let encryptedSize: UInt64
init?(bucketNumber: Int) {
self.bucketNumber = max(bucketNumber, Constants.smallestBucketNumber)
let plaintextSize = UInt64(exactly: floor(pow(Constants.paddingMultiplier, Double(self.bucketNumber))))
guard let plaintextSize else {
return nil
}
self.plaintextSize = plaintextSize
let encryptedSize = Self.addingEncryptionOverhead(to: plaintextSize)
guard let encryptedSize else {
return nil
}
self.encryptedSize = encryptedSize
}
static func addingEncryptionOverhead(to paddedValue: UInt64) -> UInt64? {
let result = paddedValue.addingReportingOverflow(
Constants.ivLength
+ Constants.blockLength
- paddedValue % Constants.blockLength
+ Constants.hmacLength,
)
if result.overflow {
return nil
}
return result.partialValue
}
static func forUnpaddedPlaintextSize(_ unpaddedPlaintextSize: UInt64) -> PaddingBucket? {
let bucketNumber: Int
if unpaddedPlaintextSize == 0 {
bucketNumber = 0
} else {
bucketNumber = Int(ceil(log(Double(unpaddedPlaintextSize)) / log(Constants.paddingMultiplier)))
}
return PaddingBucket(bucketNumber: bucketNumber)
}
static func forEncryptedSizeLimit(_ encryptedSize: UInt64) -> PaddingBucket {
let worstCasePlaintextLimit = encryptedSize.subtractingReportingOverflow(
Constants.ivLength
// When computing the `encryptedSize`, we add 1 to 16 bytes of
// `blockLength` padding. We always subtract 16 here (as a worst case) and
// then check the next bucket to handle values near the boundary.
+ Constants.blockLength
+ Constants.hmacLength,
)
if worstCasePlaintextLimit.overflow || worstCasePlaintextLimit.partialValue == 0 {
return PaddingBucket(bucketNumber: 0)!
}
// Taking the `floor(...)` here may cause us to pick a bucket one smaller
// than we should when `encryptedSize` is exactly the size of a bucket.
// (This happens when `plaintextSize` has a fractional component that gets
// floored.) We already need to check the next bucket to handle the PKCS7
// padding, so we rely on that check to handle this off-by-one as well.
let worstCaseBucketNumber = floor(log(Double(worstCasePlaintextLimit.partialValue)) / log(Constants.paddingMultiplier))
// We check one optimistic bucket because the minimum spacing is 27 bytes
// (which is larger than the 15 + 1 worst-case bytes mentioned above).
let optimisticPaddingBucket = PaddingBucket(bucketNumber: Int(worstCaseBucketNumber) + 1)
if let optimisticPaddingBucket, optimisticPaddingBucket.encryptedSize <= encryptedSize {
return optimisticPaddingBucket
}
// By definition, this bucket can't overflow the encrypted size limit.
return PaddingBucket(bucketNumber: Int(worstCaseBucketNumber))!
}
}