Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Backups/Archiving/BackupNonceMetadataStore.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import CryptoKit
public import LibSignalClient

/// Container for types wrapping raw data that relates to backup future secrecy nonces.
public enum BackupNonce {

    /// An imprecise upper bound on the byte length of the MetadataHeader we prepend to
    /// the backup file, plus the length of the file signature and varint length we put before it.
    /// Before we download the full backup file, we fetch the first N bytes so we can
    /// pull out the header to talk to SVRB before proceeding. This is how many bytes
    /// we initially fetch. If this fails, we fetch more. It is therefore most performant
    /// if this value is a reasonably tight overestimate of the length of the header, even if that
    /// length is variable.
    static let metadataHeaderByteLengthUpperBound: UInt16 =
        UInt16(magicFileSignature.count)
            + varintLengthUpperBound
            + metadataHeaderLengthUpperBound

    /// SBACKUP\x01
    static let magicFileSignature = Data([83, 66, 65, 67, 75, 85, 80, 01])
    static let varintLengthUpperBound: UInt16 = 5
    static let metadataHeaderLengthUpperBound: UInt16 = 200

    /// The header we prepend in plaintext to every backup that clients treat as a black box
    /// but contains the necessary data to derive the encryption key for the rest of the backup
    /// file, when combined with the AEP and the nonce data recovered from SVRB.
    ///
    /// This data can be (and is) exposed to the server, as it is in plaintext at the front
    /// of the backup file we upload to CDN.
    public struct MetadataHeader {
        public let data: Data

        public init(data: Data) {
            self.data = data
        }
    }

    /// Metadata used to generate the nonce and header for the _next_ backup
    /// we create.
    /// Backup N can be decrypted using Nonce N or Nonce {N+1}. This metadata
    /// deterministically produces Nonce {N+1}. We persist it when we create
    /// Backup N and read it back later when we create Backup {N+1} so that
    /// when we upload the next nonce to SVRB is it Nonce {N+1} and can be used
    /// to decrypt the prior backup, in case the next backup's upload fails.
    public struct NextSecretMetadata {
        public let data: Data

        public init(data: Data) {
            self.data = data
        }
    }
}

extension BackupNonce.MetadataHeader {

    public enum ParsingError: Error {
        case unrecognizedFileSignature
        case dataMissingOrEmpty
        case headerTooLarge
        // Includes min byte length needed
        case moreDataNeeded(UInt16)
    }

    /// Parse a MetadataHeader from the first N bytes of a backup file.
    /// If the provided bytes do not cover the complete header, throws a ``ParsingError/moreDataNeeded(_:)`` error
    /// with the minimum necessary bytes to try parsing again.
    public static func from(prefixBytes: Data?) throws(ParsingError) -> BackupNonce.MetadataHeader {
        guard var rawData = prefixBytes?.nilIfEmpty else {
            owsFailDebug("Missing prefix data")
            throw .dataMissingOrEmpty
        }
        let oldRawDataCount = rawData.count

        // Check the file signature
        let fileSignatureLength = BackupNonce.magicFileSignature.count
        guard rawData.count > fileSignatureLength else {
            throw .moreDataNeeded(BackupNonce.metadataHeaderByteLengthUpperBound)
        }
        guard rawData.prefix(fileSignatureLength) == BackupNonce.magicFileSignature else {
            throw .unrecognizedFileSignature
        }
        rawData.removeFirst(fileSignatureLength)

        // Read the varint length of the header metadata.
        let headerLength = try? rawData.removeFirstVarint()
        guard let headerLength, headerLength > 0 else {
            throw .moreDataNeeded(BackupNonce.metadataHeaderByteLengthUpperBound)
        }
        guard rawData.count >= headerLength else {
            let signatureAndVarintLength = UInt64(oldRawDataCount - rawData.count)
            let (minLength, overflow) = signatureAndVarintLength.addingReportingOverflow(headerLength)
            guard !overflow, let minLength = UInt16(exactly: minLength) else {
                // We enforce via swift compiler that the header fits in 2^16 bytes, which is
                // way overkill and should always be enough. This should never happen.
                owsFailDebug("Header larger than 2^16 bytes!")
                throw .headerTooLarge
            }
            throw .moreDataNeeded(minLength)
        }
        let rawHeader = rawData.prefix(Int(headerLength))
        return BackupNonce.MetadataHeader(data: rawHeader)
    }

    /// Produce the full serialized prefix we should add to the backup file, including the file signature,
    /// varint header length, and header itself.
    public func serializedBackupFilePrefix() -> Data {
        let headerData = self.data
        let varint = ChunkedOutputStreamTransform.writeVariableLengthUInt32(UInt32(headerData.count))
        return BackupNonce.magicFileSignature + varint + headerData
    }
}

public class BackupNonceMetadataStore {

    private let kvStore = KeyValueStore(collection: "BackupNonceMetadataStore")

    public init() {}

    /// Get the last forward secrecy token, which can be used to decrypt the last backup
    /// that was created using the provided backupKey. Returns nil if no previous backup
    /// was created (or creation was interrupted) or if the last one was created using
    /// a different backup key (e.g. an AEP rotation happened between then and now).
    public func getLastForwardSecrecyToken(
        for backupKey: MessageRootBackupKey,
        tx: DBReadTransaction,
    ) throws -> BackupForwardSecrecyToken? {
        var sha = SHA256()
        sha.update(data: backupKey.serialize())
        let hashedBackupKey = Data(sha.finalize())
        guard
            let associatedBackupKeyHash = kvStore.getData(Keys.lastForwardSecrecyTokenAssociatedBackupKeyHash, transaction: tx),
            associatedBackupKeyHash == hashedBackupKey
        else {
            return nil
        }
        return try kvStore.getData(Keys.lastForwardSecrecyToken, transaction: tx).map(BackupForwardSecrecyToken.init(contents:))
    }

    /// We should only call this method in one place only:
    /// Immediately after successfuly uploading a backup file to CDN using the MetadataHeader from the same
    /// StoreBackupResponse as this BackupForwardSecrecyToken.
    ///
    /// - parameter backupKey: The message backup key used to encrypt the just-created backup.
    public func setLastForwardSecrecyToken(
        _ token: BackupForwardSecrecyToken,
        for backupKey: MessageRootBackupKey,
        tx: DBWriteTransaction,
    ) {
        var sha = SHA256()
        sha.update(data: backupKey.serialize())
        let hashedBackupKey = Data(sha.finalize())
        kvStore.setData(hashedBackupKey, key: Keys.lastForwardSecrecyTokenAssociatedBackupKeyHash, transaction: tx)
        kvStore.setData(token.serialize(), key: Keys.lastForwardSecrecyToken, transaction: tx)
    }

    /// Get the next "secret metadata" which should be used to encrypt the next backup
    /// we create using the provided backupKey. Returns nil if no previous backup
    /// was created (or creation was interrupted) or if the last one was created using
    /// a different backup key (e.g. an AEP rotation happened between then and now).
    public func getNextSecretMetadata(
        for backupKey: MessageRootBackupKey,
        tx: DBReadTransaction,
    ) -> BackupNonce.NextSecretMetadata? {
        var sha = SHA256()
        sha.update(data: backupKey.serialize())
        let hashedBackupKey = Data(sha.finalize())
        guard
            let associatedBackupKeyHash = kvStore.getData(Keys.nextSecretMetadataAssociatedBackupKeyHash, transaction: tx),
            associatedBackupKeyHash == hashedBackupKey
        else {
            return nil
        }
        return kvStore.getData(Keys.nextSecretMetadata, transaction: tx).map(BackupNonce.NextSecretMetadata.init(data:))
    }

    /// We should only call this method in two places:
    /// 1. Immediately after successfuly uploading a backup file to CDN using the MetadataHeader from the same
    ///  StoreBackupResponse as this NextSecretMetadata.
    /// 2. As an initialization step the very first time we create a backup, since there was no prior backup to pull
    ///  the "next" metadata from.
    /// 3. After restoring a backup on a new device, with the nextSecretMetadata we got from the SVRB response,
    /// to continue the chain on this device when it makes a backup for the first time.
    ///
    /// - parameter backupKey: The message backup key used to encrypt the just-created/restored backup.
    public func setNextSecretMetadata(
        _ metadata: BackupNonce.NextSecretMetadata,
        for backupKey: MessageRootBackupKey,
        tx: DBWriteTransaction,
    ) {
        var sha = SHA256()
        sha.update(data: backupKey.serialize())
        let hashedBackupKey = Data(sha.finalize())
        kvStore.setData(hashedBackupKey, key: Keys.nextSecretMetadataAssociatedBackupKeyHash, transaction: tx)
        kvStore.setData(metadata.data, key: Keys.nextSecretMetadata, transaction: tx)
    }

    public func deleteNextSecretMetadata(tx: DBWriteTransaction) {
        kvStore.removeValue(forKey: Keys.nextSecretMetadata, transaction: tx)
        kvStore.removeValue(forKey: Keys.nextSecretMetadataAssociatedBackupKeyHash, transaction: tx)
    }

    private enum Keys {
        static let lastForwardSecrecyToken = "lastForwardSecrecyToken"
        static let nextSecretMetadata = "nextSecretMetadata"
        /// The backup key for which the above two values are valid;
        /// if the backup key changes these values shouldn't be used.
        /// Technically the two associated keys should always be the same value, but they get set separately
        /// so for compilter-level correctness we store separately also.
        static let lastForwardSecrecyTokenAssociatedBackupKeyHash = "lastForwardSecrecyTokenAssociatedBackupKeyHash"
        static let nextSecretMetadataAssociatedBackupKeyHash = "nextSecretMetadataAssociatedBackupKeyHash"
    }
}