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

499 lines
20 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
import Foundation
/// Represents an attachment; a file on local disk and/or a pointer to a file on a CDN.
public class Attachment {
public typealias IDType = Int64
/// SQLite row id.
public let id: IDType
/// Nil for:
/// * non-visual-media attachments
/// * undownloaded attachments where the sender didn't include the value.
/// Otherwise this contains the value from the sender for undownloaded attachments,
/// and our locally computed blurhash value for downloading attachments.
public let blurHash: String?
/// MIME type we get from the attachment's sender, known even before downloading the attachment.
/// **If undownloaded, unverified (spoofable by the sender) and may not match the type of the actual bytes.**
/// If downloaded, check ``AttachmentStream/contentType`` for a validated representation of the type..
public let mimeType: String
/// Encryption key used for the local file AND media tier.
/// If from an incoming message, we get this from the proto, and can reuse it for local and media backup encryption.
/// If outgoing, we generate the key ourselves when we create the attachment.
public let encryptionKey: Data
public let streamInfo: StreamInfo?
/// Information for the transit tier upload, if known to be uploaded.
public let transitTierInfo: TransitTierInfo?
/// Used for quoted reply thumbnail attachments.
/// The id of the quoted reply's target message's attachment that is to be thumbnail'ed.
/// Only relevant for non-streams. At "download" time instead of using the transit tier info
/// as the source we use the original attachment's file. Once this attachment is a stream,
/// this field should be set to nil (but should just be ignored regardless).
public let originalAttachmentIdForQuotedReply: Attachment.IDType?
/// MediaName used for backups (but assigned even if backups disabled).
/// Nonnull if downloaded OR if restored from a backup.
public let mediaName: String?
/// If null, the resource has not been uploaded to the media tier.
public let mediaTierInfo: MediaTierInfo?
/// Not to be confused with thumbnails used for rendering, or those created for quoted message replies.
/// This thumbnail is exclusively used for backup purposes.
/// If null, the thumbnail resource has not been uploaded to the media tier.
public let thumbnailMediaTierInfo: ThumbnailMediaTierInfo?
/// Filepath to the encrypted thumbnail file on local disk.
/// Not to be confused with thumbnails used for rendering, or those created for quoted message replies.
/// This thumbnail is exclusively used for backup purposes.
public let localRelativeFilePathThumbnail: String?
// MARK: - Inner structs
/// Information supporting "streaming" video, which requires computing an
/// "incremental" MAC rather than one big HMAC verification on the
/// fully-downloaded file.
public struct IncrementalMacInfo: Equatable {
public let mac: Data
public let chunkSize: UInt32
}
/// Information for the "stream" (the attachment downloaded and locally available).
public struct StreamInfo {
/// Sha256 hash of the plaintext of the media content. Used to deduplicate incoming media.
public let sha256ContentHash: Data
/// Byte count of the encrypted fullsize resource
public let encryptedByteCount: UInt32
/// Byte count of the decrypted fullsize resource
public let unencryptedByteCount: UInt32
/// For downloaded attachments, the validated type of content in the actual file.
public let contentType: ContentType
/// File digest info.
///
/// SHA256Hash(iv + cyphertext + hmac),
/// (iv + cyphertext + hmac) is the thing we actually upload to the CDN server, which uses
/// the ``encryptionKey`` field.
///
/// Generated locally for outgoing attachments.
/// Validated for downloaded attachments.
public let digestSHA256Ciphertext: Data
/// Filepath to the encrypted fullsize media file on local disk.
public let localRelativeFilePath: String
}
public struct TransitTierInfo: Equatable {
/// CDN number for the upload in the transit tier (or nil if not uploaded).
public let cdnNumber: UInt32
/// CDN key for the upload in the transit tier (or nil if not uploaded).
public let cdnKey: String
/// If outgoing: Local time the attachment was uploaded to the transit tier, or nil if not uploaded.
/// If incoming: timestamp on the message the attachment came in on.
/// Used to determine whether reuploading is necessary for e.g. forwarding.
public let uploadTimestamp: UInt64
/// Encryption key used on this transit tier upload.
/// May be the same as the local stream encryption key, or may have been rotated for sending.
public let encryptionKey: Data
/// Expected byte count after decrypting the resource off the transit tier (and removing padding).
/// Provided by the sender of incoming attachments.
public let unencryptedByteCount: UInt32?
/// SHA256Hash(iv + cyphertext + hmac),
/// (iv + cyphertext + hmac) is the thing we actually upload to the CDN server, which uses
/// the ``TransitTierInfo.encryptionKey`` field.
///
/// Generated locally for outgoing attachments.
/// For incoming attachments, taken off the service proto. If validation fails, the download is rejected.
public let digestSHA256Ciphertext: Data
/// Incremental mac info used for streaming, if available. Only set for streamable types.
public let incrementalMacInfo: IncrementalMacInfo?
/// Timestamp we last tried (and failed) to download from the transit tier.
/// Nil if we have not tried or have successfully downloaded.
public let lastDownloadAttemptTimestamp: UInt64?
}
public struct MediaTierInfo {
/// CDN number for the fullsize upload in the media tier.
/// If nil, that means there _might_ be an upload from a prior device that happened after
/// that device generated the backup this was restored from. The cdn number (and presence
/// of the upload) can be discovered via the list endpoint.
public let cdnNumber: UInt32?
/// Expected byte count after decrypting the resource off the media tier (and removing padding).
/// Provided by the sender of incoming attachments.
public let unencryptedByteCount: UInt32
/// SHA256Hash(iv + cyphertext + hmac),
/// (iv + cyphertext + hmac) is the thing we actually upload to the CDN server, which uses
/// the ``TransitTierInfo.encryptionKey`` field.
///
/// Equivalent to `StreamInfo.digestSHA256Ciphertext`, but may be available
/// if the rest of `StreamInfo` is unavailable (e.g. after a restore).
public let digestSHA256Ciphertext: Data
/// Incremental mac info used for streaming, if available. Only set for streamable mime types.
public let incrementalMacInfo: IncrementalMacInfo?
/// If the value in this column doesnt match the current Backup Subscription Era,
/// it should also be considered un-uploaded.
/// Set to the current era when uploaded.
public let uploadEra: String
/// Timestamp we last tried (and failed) to download from the media tier.
/// Nil if we have not tried or have successfully downloaded.
public let lastDownloadAttemptTimestamp: UInt64?
}
public struct ThumbnailMediaTierInfo {
/// CDN number for the thumbnail upload in the media tier.
/// If nil, that means there _might_ be an upload from a prior device that happened after
/// that device generated the backup this was restored from. The cdn number (and presence
/// of the upload) can be discovered via the list endpoint.
public let cdnNumber: UInt32?
/// If the value in this column doesnt match the current Backup Subscription Era,
/// it should also be considered un-uploaded.
/// Set to the current era when uploaded.
public let uploadEra: String
/// Timestamp we last tried (and failed) to download the thumbnail from the media tier.
/// Nil if we have not tried or have successfully downloaded.
public let lastDownloadAttemptTimestamp: UInt64?
}
// MARK: - Init
internal init(record: Attachment.Record) throws {
guard let id = record.sqliteId else {
throw OWSAssertionError("Attachment is only for inserted records")
}
let contentType = try ContentType(
raw: record.contentType,
cachedAudioDurationSeconds: record.cachedAudioDurationSeconds,
cachedMediaHeightPixels: record.cachedMediaHeightPixels,
cachedMediaWidthPixels: record.cachedMediaWidthPixels,
cachedVideoDurationSeconds: record.cachedVideoDurationSeconds,
audioWaveformRelativeFilePath: record.audioWaveformRelativeFilePath,
videoStillFrameRelativeFilePath: record.videoStillFrameRelativeFilePath
)
self.id = id
self.blurHash = record.blurHash
self.mimeType = record.mimeType
self.encryptionKey = record.encryptionKey
self.originalAttachmentIdForQuotedReply = record.originalAttachmentIdForQuotedReply
self.mediaName = record.mediaName
self.localRelativeFilePathThumbnail = record.localRelativeFilePathThumbnail
self.streamInfo = StreamInfo(
sha256ContentHash: record.sha256ContentHash,
encryptedByteCount: record.encryptedByteCount,
unencryptedByteCount: record.unencryptedByteCount,
contentType: contentType,
digestSHA256Ciphertext: record.digestSHA256Ciphertext,
localRelativeFilePath: record.localRelativeFilePath
)
self.transitTierInfo = TransitTierInfo(
cdnNumber: record.transitCdnNumber,
cdnKey: record.transitCdnKey,
uploadTimestamp: record.transitUploadTimestamp,
encryptionKey: record.transitEncryptionKey,
unencryptedByteCount: record.transitUnencryptedByteCount,
digestSHA256Ciphertext: record.transitDigestSHA256Ciphertext,
lastDownloadAttemptTimestamp: record.lastTransitDownloadAttemptTimestamp,
incrementalMac: record.transitTierIncrementalMac,
incrementalMacChunkSize: record.transitTierIncrementalMacChunkSize
)
self.mediaTierInfo = MediaTierInfo(
cdnNumber: record.mediaTierCdnNumber,
unencryptedByteCount: record.mediaTierUnencryptedByteCount ?? record.unencryptedByteCount,
digestSHA256Ciphertext: record.mediaTierDigestSHA256Ciphertext ?? record.digestSHA256Ciphertext,
uploadEra: record.mediaTierUploadEra,
lastDownloadAttemptTimestamp: record.lastMediaTierDownloadAttemptTimestamp,
incrementalMac: record.mediaTierIncrementalMac,
incrementalMacChunkSize: record.mediaTierIncrementalMacChunkSize
)
self.thumbnailMediaTierInfo = ThumbnailMediaTierInfo(
cdnNumber: record.thumbnailCdnNumber,
uploadEra: record.thumbnailUploadEra,
lastDownloadAttemptTimestamp: record.lastThumbnailDownloadAttemptTimestamp
)
}
public var isUploadedToTransitTier: Bool {
return transitTierInfo != nil
}
public var hasMediaTierInfo: Bool {
return mediaTierInfo != nil
}
public func asStream() -> AttachmentStream? {
return AttachmentStream(attachment: self)
}
public func asTransitTierPointer() -> AttachmentTransitPointer? {
return AttachmentTransitPointer(attachment: self)
}
public func asBackupThumbnail() -> AttachmentBackupThumbnail? {
return AttachmentBackupThumbnail(attachment: self)
}
public static func mediaName(digestSHA256Ciphertext: Data) -> String {
// We use the hexadecimal-encoded digest as the media name.
// This ensures media name collisions occur only between the
// same attachment contents encrypted with the same key.
return digestSHA256Ciphertext.hexadecimalString
}
public static func uploadEra(backupSubscriptionId: Data) throws -> String {
// We just hash and base64 encode the subscription id as the "upload era".
// All the "era" means is if it changes, all existing uploads to the backup
// tier should be considered invalid and needing reupload.
// Hash so as to avoid putting the unsafe-to-log subscription id in more places.
var hasher = SHA256()
hasher.update(data: backupSubscriptionId)
return Data(hasher.finalize()).base64EncodedString()
}
public enum TransitUploadStrategy {
case reuseExistingUpload(Upload.ReusedUploadMetadata)
case reuseStreamEncryption(Upload.LocalUploadMetadata)
case freshUpload(AttachmentStream)
case cannotUpload
}
public func transitUploadStrategy(dateProvider: DateProvider) -> TransitUploadStrategy {
// We never allow uploads of data we don't have locally.
guard let stream = self.asStream() else {
return .cannotUpload
}
let metadata = Upload.LocalUploadMetadata(
fileUrl: stream.fileURL,
key: encryptionKey,
digest: stream.info.digestSHA256Ciphertext,
encryptedDataLength: stream.info.encryptedByteCount,
plaintextDataLength: stream.info.unencryptedByteCount
)
if
// We have a prior upload
let transitTierInfo,
// And we are still in the window to reuse it
dateProvider().timeIntervalSince(
Date(millisecondsSince1970: transitTierInfo.uploadTimestamp)
) <= Upload.Constants.uploadReuseWindow
{
// We have unexpired transit tier info. Reuse that upload.
return .reuseExistingUpload(
.init(
cdnKey: transitTierInfo.cdnKey,
cdnNumber: transitTierInfo.cdnNumber,
key: transitTierInfo.encryptionKey,
digest: transitTierInfo.digestSHA256Ciphertext,
// Okay to fall back to our local data length even if the original sender
// didn't include it; we now know it from the local file.
plaintextDataLength: transitTierInfo.unencryptedByteCount ?? metadata.plaintextDataLength,
// Encryped length is the same regardless of the key used.
encryptedDataLength: metadata.encryptedDataLength
)
)
} else if
// This device has never uploaded
transitTierInfo == nil,
// No media tier info either
mediaTierInfo == nil
{
// Reuse our local encryption for sending.
// Without this, we'd have to reupload all our outgoing attacments
// in order to copy them to the media tier.
return .reuseStreamEncryption(metadata)
} else {
// Upload from scratch
return .freshUpload(stream)
}
}
}
// MARK: -
private extension Attachment.StreamInfo {
init?(
sha256ContentHash: Data?,
encryptedByteCount: UInt32?,
unencryptedByteCount: UInt32?,
contentType: Attachment.ContentType?,
digestSHA256Ciphertext: Data?,
localRelativeFilePath: String?
) {
guard
let sha256ContentHash,
let encryptedByteCount,
let unencryptedByteCount,
let contentType,
let digestSHA256Ciphertext,
let localRelativeFilePath
else {
owsAssertDebug(
sha256ContentHash == nil
&& encryptedByteCount == nil
&& unencryptedByteCount == nil
&& contentType == nil
&& localRelativeFilePath == nil,
"Have partial stream info!"
)
return nil
}
self.sha256ContentHash = sha256ContentHash
self.encryptedByteCount = encryptedByteCount
self.unencryptedByteCount = unencryptedByteCount
self.contentType = contentType
self.digestSHA256Ciphertext = digestSHA256Ciphertext
self.localRelativeFilePath = localRelativeFilePath
}
}
private extension Attachment.TransitTierInfo {
init?(
cdnNumber: UInt32?,
cdnKey: String?,
uploadTimestamp: UInt64?,
encryptionKey: Data?,
unencryptedByteCount: UInt32?,
digestSHA256Ciphertext: Data?,
lastDownloadAttemptTimestamp: UInt64?,
incrementalMac: Data?,
incrementalMacChunkSize: UInt32?
) {
guard
let cdnNumber,
let cdnKey,
let uploadTimestamp,
let encryptionKey,
let unencryptedByteCount,
let digestSHA256Ciphertext
else {
owsAssertDebug(
cdnNumber == nil
&& cdnKey == nil
&& uploadTimestamp == nil
&& encryptionKey == nil
&& unencryptedByteCount == nil
&& digestSHA256Ciphertext == nil,
"Have partial transit cdn info!"
)
return nil
}
self.cdnNumber = cdnNumber
self.cdnKey = cdnKey
self.uploadTimestamp = uploadTimestamp
self.lastDownloadAttemptTimestamp = lastDownloadAttemptTimestamp
self.encryptionKey = encryptionKey
self.unencryptedByteCount = unencryptedByteCount
self.digestSHA256Ciphertext = digestSHA256Ciphertext
if let incrementalMac, let incrementalMacChunkSize {
self.incrementalMacInfo = .init(mac: incrementalMac, chunkSize: incrementalMacChunkSize)
} else {
owsAssertDebug(
incrementalMac == nil && incrementalMacChunkSize == nil,
"Have partial transit tier incremental mac info!"
)
self.incrementalMacInfo = nil
}
}
}
private extension Attachment.MediaTierInfo {
init?(
cdnNumber: UInt32?,
unencryptedByteCount: UInt32?,
digestSHA256Ciphertext: Data?,
uploadEra: String?,
lastDownloadAttemptTimestamp: UInt64?,
incrementalMac: Data?,
incrementalMacChunkSize: UInt32?
) {
guard
let uploadEra,
let unencryptedByteCount,
let digestSHA256Ciphertext
else {
owsAssertDebug(
uploadEra == nil,
"Have partial media cdn info!"
)
return nil
}
self.cdnNumber = cdnNumber
self.unencryptedByteCount = unencryptedByteCount
self.digestSHA256Ciphertext = digestSHA256Ciphertext
self.uploadEra = uploadEra
self.lastDownloadAttemptTimestamp = lastDownloadAttemptTimestamp
if let incrementalMac, let incrementalMacChunkSize {
self.incrementalMacInfo = .init(mac: incrementalMac, chunkSize: incrementalMacChunkSize)
} else {
owsAssertDebug(
incrementalMac == nil && incrementalMacChunkSize == nil,
"Have partial media tier incremental mac info!"
)
self.incrementalMacInfo = nil
}
}
}
private extension Attachment.ThumbnailMediaTierInfo {
init?(
cdnNumber: UInt32?,
uploadEra: String?,
lastDownloadAttemptTimestamp: UInt64?
) {
guard
let uploadEra
else {
owsAssertDebug(
uploadEra == nil,
"Have partial thumbnail media cdn info!"
)
return nil
}
self.cdnNumber = cdnNumber
self.uploadEra = uploadEra
self.lastDownloadAttemptTimestamp = lastDownloadAttemptTimestamp
}
}
private extension Attachment.IncrementalMacInfo {
init?(
mac: Data?,
chunkSize: UInt32?
) {
guard let mac, let chunkSize else { return nil }
self.mac = mac
self.chunkSize = chunkSize
}
}