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

import Foundation

public class SVRAuthCredentialStorageImpl: SVRAuthCredentialStorage {

    public init() {}

    // MARK: SVR2

    public func storeAuthCredentialForCurrentUsername(_ credential: SVR2AuthCredential, _ transaction: DBWriteTransaction) {
        let credential = AuthCredential.from(credential)
        updateCredentials(for: .svr2, transaction) {
            // Sorting is handled by updateCredentials
            $0.append(credential)
        }
        setCurrentUsername(credential.username, for: .svr2, transaction)
    }

    public func getAuthCredentials(_ transaction: DBReadTransaction) -> [SVR2AuthCredential] {
        return consolidateLocalAndiCloud(for: .svr2, transaction).map { $0.toSVR2Credential() }
    }

    public func getAuthCredentialForCurrentUser(_ transaction: DBReadTransaction) -> SVR2AuthCredential? {
        return consolidateLocalAndiCloud(for: .svr2, transaction).first(where: {
            return $0.username == currentUsername(for: .svr2, transaction)
        })?.toSVR2Credential()
    }

    public func deleteInvalidCredentials(_ invalidCredentials: [SVR2AuthCredential], _ transaction: DBWriteTransaction) {
        updateCredentials(for: .svr2, transaction) {
            $0.removeAll(where: { existingCredential in
                invalidCredentials.contains(where: { invalidCredential in
                    existingCredential.isEqual(to: invalidCredential)
                })
            })
        }
    }

    public func removeSVR2CredentialsForCurrentUser(_ transaction: DBWriteTransaction) {
        let currentUsername = currentUsername(for: .svr2, transaction)
        updateCredentials(for: .svr2, transaction) {
            $0.removeAll(where: { existingCredential in
                existingCredential.username == currentUsername
            })
        }
    }

    // MARK: - Local Storage

    // Also write any KBS auth credentials we know about to local, encrypted database storage,
    // so we can reuse them locally even if iCloud is disabled or fails for any reason.

    private var kvStores = [CredentialType: KeyValueStore]()

    private func kvStore(for type: CredentialType) -> KeyValueStore {
        if let existing = kvStores[type] {
            return existing
        }
        let new = KeyValueStore(collection: type.kvStoreCollectionName)
        kvStores[type] = new
        return new
    }

    private static let usernameKey = "username"

    private func currentUsername(
        for type: CredentialType,
        _ transaction: DBReadTransaction,
    ) -> String? {
        return kvStore(for: type).getString(Self.usernameKey, transaction: transaction)
    }

    private func setCurrentUsername(_ newValue: String?, for type: CredentialType, _ transaction: DBWriteTransaction) {
        kvStore(for: type).setString(newValue, key: Self.usernameKey, transaction: transaction)
    }

    private static let localCredentialsKey = "credentials"

    private func localCredentials(for type: CredentialType, _ transaction: DBReadTransaction) -> [AuthCredential] {
        guard let rawData = kvStore(for: type).getData(Self.localCredentialsKey, transaction: transaction) else {
            return []
        }
        let decoder = JSONDecoder()
        guard let credentials = try? decoder.decode([AuthCredential].self, from: rawData) else {
            owsFailBeta("Unable to decode kbs auth credential(s)")
            return []
        }
        return credentials
    }

    private func setLocalCredentials(_ newValue: [AuthCredential], for type: CredentialType, _ transaction: DBWriteTransaction) {
        let encoder = JSONEncoder()
        guard let encoded = try? encoder.encode(newValue) else {
            owsFailBeta("Unable to encode kbs auth credential(s)")
            return
        }
        kvStore(for: type).setData(encoded, key: Self.localCredentialsKey, transaction: transaction)
    }

    // MARK: - iCloud Re-registration Support

    // We write KBS auth credentials we know about to iCloud's key value store to be synced
    // between a user's devices. (encrypted, as per apple's documentation)
    // Having this credential lets us skip phone number verification during registation,
    // for example if the user loses their phone and gets a new one and re-registers with the same number.

    // The user still **MUST** know their PIN to access their account data (and to get in at all, if
    // reglock is enabled). The credential on its own does not grant any account information;
    // only the ability to make PIN guesses.
    // Normally, phone number verification (SMS code) is required to fetch one of these
    // credentials first, before proceeding to attempt to enter the PIN code.

    private func getiCloudCredentials(for type: CredentialType) -> [AuthCredential] {
        guard let rawData = NSUbiquitousKeyValueStore.default.data(forKey: type.iCloudCredentialsKey) else {
            return []
        }
        let decoder = JSONDecoder()
        guard let credentials = try? decoder.decode([AuthCredential].self, from: rawData) else {
            owsFailBeta("Unable to decode kbs auth credential(s) from iCloud")
            return []
        }
        return credentials
    }

    private func setiCloudCredentials(_ newValue: [AuthCredential], for type: CredentialType) {
        let encoder = JSONEncoder()
        guard let encoded = try? encoder.encode(newValue) else {
            owsFailBeta("Unable to encode kbs auth credential(s) to iCloud")
            return
        }
        NSUbiquitousKeyValueStore.default.set(encoded, forKey: type.iCloudCredentialsKey)
    }

    // MARK: - Helpers

    private func updateCredentials(
        for type: CredentialType,
        _ transaction: DBWriteTransaction,
        _ block: (inout [AuthCredential]) -> Void,
    ) {
        var credentials = consolidateLocalAndiCloud(for: type, transaction)
        block(&credentials)
        setLocalCredentials(credentials, for: type, transaction)
        // Consolidate again so that we sort and truncate.
        credentials = consolidateLocalAndiCloud(for: type, transaction)
        setLocalCredentials(credentials, for: type, transaction)
        setiCloudCredentials(credentials, for: type)
    }

    private func consolidateLocalAndiCloud(for type: CredentialType, _ transaction: DBReadTransaction) -> [AuthCredential] {
        return Self.consolidateCredentials(
            allUnsortedCredentials: localCredentials(for: type, transaction) + getiCloudCredentials(for: type),
        )
    }

    // Exposed for testing.
    static func consolidateCredentials(
        allUnsortedCredentials: [AuthCredential],
    ) -> [AuthCredential] {
        let groupedByUsername = Dictionary(grouping: allUnsortedCredentials, by: \.username).values

        let sort: (AuthCredential, AuthCredential) -> Bool = {
            // newer ones first
            return $0.insertionTime > $1.insertionTime
        }

        // The size limit is small; just merge and sort even though
        // this could be done in O(n).
        let consolidated =
            groupedByUsername.compactMap {
                // First group by password; two with the same password are identical
                // and we should use the *oldest* one so we don't update the timestamp
                // if it is re-inserted.
                return Dictionary(grouping: $0, by: \.password)
                    .values
                    .compactMap {
                        // max when sorted newest to oldest = oldest
                        return $0.max(by: sort)
                    }
                    // min when sorted newest to oldest = newest
                    .min(by: sort)
            }
            // Sort by time, which is set locally and subject to
            // clock shenanigans, but this is all best effort anyway.
            .sorted(by: sort)
            // Take the first N up to the limit
            .prefix(SVR.maxSVRAuthCredentialsBackedUp)
        return Array(consolidated)
    }

    enum CredentialType: Hashable {
        case svr2

        var iCloudCredentialsKey: String {
            switch self {
            case .svr2:
                return "signal_svr2_credentials"
            }
        }

        var kvStoreCollectionName: String {
            switch self {
            case .svr2:
                return "SVR2AuthCredential"
            }
        }
    }

    struct AuthCredential: Codable, Equatable {
        let username: String
        let password: String
        let insertionTime: Date

        static func from(_ credential: SVR2AuthCredential) -> Self {
            return AuthCredential(
                username: credential.credential.username,
                password: credential.credential.password,
                insertionTime: Date(),
            )
        }

        private var credential: RemoteAttestation.Auth {
            return RemoteAttestation.Auth(username: username, password: password)
        }

        func toSVR2Credential() -> SVR2AuthCredential {
            return SVR2AuthCredential(credential: credential)
        }

        func isEqual(to credential: SVR2AuthCredential) -> Bool {
            // Compare everything but the insertion time, make that equal.
            return AuthCredential(
                username: credential.credential.username,
                password: credential.credential.password,
                insertionTime: self.insertionTime,
            ) == self
        }
    }
}