chromium/ios/chrome/browser/credential_provider/model/credential_provider_util.mm

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/browser/credential_provider/model/credential_provider_util.h"

#import <CommonCrypto/CommonDigest.h>

#import "base/apple/foundation_util.h"
#import "base/strings/sys_string_conversions.h"
#import "base/strings/utf_string_conversions.h"
#import "base/task/thread_pool.h"
#import "base/threading/scoped_blocking_call.h"
#import "components/password_manager/core/common/password_manager_features.h"
#import "ios/chrome/browser/favicon/model/favicon_loader.h"
#import "ios/chrome/browser/favicon/model/ios_chrome_favicon_loader_factory.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/common/app_group/app_group_constants.h"
#import "ios/chrome/common/credential_provider/archivable_credential.h"
#import "ios/chrome/common/credential_provider/archivable_credential_store.h"
#import "ios/chrome/common/credential_provider/constants.h"
#import "ios/chrome/common/credential_provider/credential.h"
#import "ios/chrome/common/ui/favicon/favicon_attributes.h"
#import "ios/chrome/common/ui/favicon/favicon_constants.h"
#import "net/base/apple/url_conversions.h"

using base::SysUTF16ToNSString;
using base::UTF8ToUTF16;

extern const char kSyncStoreHistogramName[] =
    "IOS.CredentialExtension.SyncedStore";

namespace {

// Max number of stored favicons.
int const kMaxNumberOfFavicons = 2000;

// Key used to store the favicons last sync date in the user preferences.
NSString* const kFaviconsLastSyncDatePrefKey = @"FaviconsLastSyncDatePrefKey";

// The minimum number of days between the last sync and today.
constexpr base::TimeDelta kResyncInterval = base::Days(7);

// The number of days after which to refetch an existing favicon. This number
// aims at balancing not fetching too often while ensuring the users see updated
// favicons relatively quickly.
constexpr base::TimeDelta kFaviconRefreshInterval = base::Days(14);

}  // namespace

NSString* RecordIdentifierForPasswordForm(
    const password_manager::PasswordForm& form) {
  // These are the UNIQUE keys in the login database.
  return SysUTF16ToNSString(UTF8ToUTF16(form.url.spec() + "|") +
                            form.username_element + u"|" + form.username_value +
                            u"|" + form.password_element +
                            UTF8ToUTF16("|" + form.signon_realm));
}

NSString* GetFaviconFileKey(const GURL& url) {
  // We are using SHA256 hashing to hide the website's URL and also to be able
  // to use as the key for the storage (since the character string that makes up
  // a URL (including the scheme and ://) isn't a valid file name).
  unsigned char result[CC_SHA256_DIGEST_LENGTH];
  CC_SHA256(url.spec().data(), url.spec().length(), result);
  return base::SysUTF8ToNSString(base::HexEncode(result));
}

void SaveFaviconToSharedAppContainer(FaviconAttributes* attributes,
                                     NSString* filename) {
  base::OnceCallback<void()> write_image = base::BindOnce(^{
    NSError* error = nil;
    NSData* data = [NSKeyedArchiver archivedDataWithRootObject:attributes
                                         requiringSecureCoding:NO
                                                         error:&error];
    if (!data || error) {
      DLOG(WARNING) << base::SysNSStringToUTF8([error description]);
      return;
    }

    base::ScopedBlockingCall scoped_blocking_call(
        FROM_HERE, base::BlockingType::WILL_BLOCK);
    NSURL* shared_favicon_attributes_folder_url =
        app_group::SharedFaviconAttributesFolder();
    NSURL* file_url = [shared_favicon_attributes_folder_url
        URLByAppendingPathComponent:filename
                        isDirectory:NO];

    // Create shared folder if it doesn't exist.
    NSFileManager* file_manager = [NSFileManager defaultManager];
    NSString* path = shared_favicon_attributes_folder_url.path;
    if (![file_manager fileExistsAtPath:path]) {
      [file_manager createDirectoryAtPath:path
              withIntermediateDirectories:YES
                               attributes:nil
                                    error:nil];
    }

    // Write to file.
    [data writeToURL:file_url atomically:YES];
  });
  base::ThreadPool::PostTask(
      FROM_HERE,
      {base::MayBlock(), base::ThreadPolicy::PREFER_BACKGROUND,
       base::TaskPriority::BEST_EFFORT},
      std::move(write_image));
}

// Returns true to continue fetching favicon if the app group storage does not
// contain more than the max number of favicons or if the verification is
// skipped.
bool ShouldContinueFetchingFavicon() {
  base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
                                                base::BlockingType::WILL_BLOCK);
  return
      [[[NSFileManager defaultManager]
          contentsOfDirectoryAtPath:app_group::SharedFaviconAttributesFolder()
                                        .path
                              error:nil] count] < kMaxNumberOfFavicons;
}

// Fetches favicon from site URL and saves it to `filename`.
void ContinueFetchingFavicon(base::WeakPtr<FaviconLoader> weak_favicon_loader,
                             const GURL& site_url,
                             NSString* filename,
                             bool fallback_to_google_server,
                             bool continue_fetching) {
  FaviconLoader* favicon_loader = weak_favicon_loader.get();
  if (!continue_fetching || !favicon_loader) {
    // Reached max number of stored favicons or favicon loader is null.
    return;
  }
  // Fallback to Google server for synced user only.
  favicon_loader->FaviconForPageUrl(
      site_url, kDesiredMediumFaviconSizePt, kMinFaviconSizePt,
      fallback_to_google_server, ^(FaviconAttributes* attributes) {
        SaveFaviconToSharedAppContainer(attributes, filename);
      });
}

void FetchFaviconForURLToPath(FaviconLoader* favicon_loader,
                              const GURL& site_url,
                              NSString* filename,
                              bool skip_max_verification,
                              bool fallback_to_google_server) {
  DCHECK(favicon_loader);
  DCHECK(filename);
  if (skip_max_verification) {
    ContinueFetchingFavicon(favicon_loader->AsWeakPtr(), site_url, filename,
                            fallback_to_google_server,
                            /* continue_fetching */ YES);
  } else {
    base::ThreadPool::PostTaskAndReplyWithResult(
        FROM_HERE,
        {base::MayBlock(), base::ThreadPolicy::PREFER_BACKGROUND,
         base::TaskPriority::BEST_EFFORT},
        base::BindOnce(&ShouldContinueFetchingFavicon),
        base::BindOnce(&ContinueFetchingFavicon, favicon_loader->AsWeakPtr(),
                       site_url, filename, fallback_to_google_server));
  }
}

// Gets the last sync date for favicons in the app group storage.
base::Time GetFaviconsLastSyncDate() {
  NSDate* last_sync_date =
      base::apple::ObjCCast<NSDate>([[NSUserDefaults standardUserDefaults]
          objectForKey:kFaviconsLastSyncDatePrefKey]);
  // If no value stored in the NSUserDefaults, consider that the last sync
  // happened forever ago.
  if (!last_sync_date) {
    return base::Time();
  }
  return base::Time::FromNSDate(last_sync_date);
}

// Sets the value of the last sync date for favicons in the app group storage.
void SetFaviconsLastSyncDate(base::Time sync_time) {
  [[NSUserDefaults standardUserDefaults]
      setObject:sync_time.ToNSDate()
         forKey:kFaviconsLastSyncDatePrefKey];
}

// Cleans up obsolete favicons from the Chrome app group storage.
void CleanUpFavicons(NSSet* excess_favicons_filenames) {
  base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
                                                base::BlockingType::WILL_BLOCK);
  NSFileManager* file_manager = [NSFileManager defaultManager];
  NSString* path = app_group::SharedFaviconAttributesFolder().path;
  NSArray* filename_list = [file_manager contentsOfDirectoryAtPath:path
                                                             error:nil];
  if (!filename_list || [filename_list count] == 0) {
    return;
  }

  ArchivableCredentialStore* archivable_store =
      [[ArchivableCredentialStore alloc]
          initWithFileURL:CredentialProviderSharedArchivableStoreURL()];
  NSArray<id<Credential>>* all_credentials = archivable_store.credentials;
  // Extract favicon filename from the credentials list.
  NSMutableSet* credential_favicon_filename_set =
      [[NSMutableSet alloc] initWithCapacity:all_credentials.count];
  for (id<Credential> credential in all_credentials) {
    if (credential.favicon &&
        ![excess_favicons_filenames containsObject:credential.favicon]) {
      [credential_favicon_filename_set addObject:credential.favicon];
    }
  }

  for (NSUInteger i = 0; i < [filename_list count]; i++) {
    NSString* filename = [filename_list objectAtIndex:i];
    if (![credential_favicon_filename_set containsObject:filename]) {
      // Remove from storage.
      NSURL* url = [app_group::SharedFaviconAttributesFolder()
          URLByAppendingPathComponent:filename
                          isDirectory:NO];
      if ([file_manager fileExistsAtPath:[url path]]) {
        [file_manager removeItemAtURL:url error:nil];
      }
    }
  }

  // Store the last sync date.
  SetFaviconsLastSyncDate(base::Time::Now());
}

void UpdateFaviconsStorage(FaviconLoader* favicon_loader,
                           bool fallback_to_google_server) {
  // Verify if the app group storage for favicons needs to be synced and
  // cleaned up by checking the last sync date.
  const base::TimeDelta time_elapsed_since_last_sync =
      base::Time::Now() - GetFaviconsLastSyncDate();
  if (time_elapsed_since_last_sync < kResyncInterval) {
    return;
  }
  ArchivableCredentialStore* archivable_store =
      [[ArchivableCredentialStore alloc]
          initWithFileURL:CredentialProviderSharedArchivableStoreURL()];
  NSArray<id<Credential>>* all_credentials = archivable_store.credentials;

  // Sort by highest rank.
  NSArray<id<Credential>>* all_credentials_rank =
      [all_credentials sortedArrayUsingComparator:^NSComparisonResult(
                           id<Credential> c1, id<Credential> c2) {
        if (c1.rank == c2.rank) {
          return NSOrderedSame;
        }
        return c1.rank > c2.rank ? NSOrderedAscending : NSOrderedDescending;
      }];

  // Truncate array if it is larger than the maximum and add the favicons
  // file name to be removed in a set.
  NSMutableSet* excess_favicons_filenames = [[NSMutableSet alloc] init];
  if ([all_credentials_rank count] > kMaxNumberOfFavicons) {
    for (NSUInteger i = kMaxNumberOfFavicons; i < all_credentials_rank.count;
         i++) {
      if ([all_credentials_rank objectAtIndex:i].favicon) {
        [excess_favicons_filenames
            addObject:[all_credentials_rank objectAtIndex:i].favicon];
      }
    }
    all_credentials_rank = [all_credentials_rank
        subarrayWithRange:NSMakeRange(0, kMaxNumberOfFavicons)];
  }

  for (id<Credential> credential : all_credentials_rank) {
    GURL url = GURL(base::SysNSStringToUTF8(credential.serviceIdentifier));
    if (!url.is_valid()) {
      // Skip fetching the favicon for credential with invalid URL.
      continue;
    }
    NSString* filename = credential.favicon;
    if (!credential.favicon) {
      // Add favicon name to the credential and update the store.
      filename = GetFaviconFileKey(url);
      ArchivableCredential* newCredential =
          [[ArchivableCredential alloc] initWithFavicon:filename
                                             credential:credential];
      if ([archivable_store
              credentialWithRecordIdentifier:newCredential.recordIdentifier]) {
        [archivable_store updateCredential:newCredential];
      } else {
        [archivable_store addCredential:newCredential];
      }
    }

    // Fetch the favicon and save it to the app group storage.
    if (filename) {
      FetchFaviconForURLToPath(favicon_loader, url, filename,
                               /*skip_max_verification=*/YES,
                               fallback_to_google_server);

      // Remove file name duplicate because it is part of the top
      // `kMaxNumberOfFavicons` credentials used by the user.
      if ([excess_favicons_filenames containsObject:filename]) {
        [excess_favicons_filenames removeObject:filename];
      }
    }
  }

  // Save changes in the credential store and call the clean up method to
  // remove obsolete favicons from the Chrome app group storage.
  [archivable_store saveDataWithCompletion:^(NSError* error) {
    base::ThreadPool::PostTask(
        FROM_HERE, {base::MayBlock(), base::TaskPriority::BEST_EFFORT},
        base::BindOnce(&CleanUpFavicons, excess_favicons_filenames));
  }];
}

void UpdateFaviconsStorageForBrowserState(
    base::WeakPtr<ChromeBrowserState> weak_browser_state,
    bool fallback_to_google_server) {
  ChromeBrowserState* browser_state = weak_browser_state.get();
  if (!browser_state) {
    return;
  }
  UpdateFaviconsStorage(
      IOSChromeFaviconLoaderFactory::GetForBrowserState(browser_state),
      fallback_to_google_server);
}

NSDictionary<NSString*, NSDate*>* GetFaviconsListAndFreshness() {
  NSURL* shared_favicon_attributes_folder_url =
      app_group::SharedFaviconAttributesFolder();

  NSFileManager* file_manager = [NSFileManager defaultManager];
  NSString* path = shared_favicon_attributes_folder_url.path;

  // If the favicon folder doesn't exist, there are not favicons stored.
  if (![file_manager fileExistsAtPath:path]) {
    return nil;
  }

  NSArray<NSString*>* fileNames = [file_manager contentsOfDirectoryAtPath:path
                                                                    error:nil];
  if (fileNames.count == 0) {
    return nil;
  }

  NSMutableDictionary<NSString*, NSDate*>* favicon_info_dict =
      [[NSMutableDictionary alloc] init];
  for (NSString* fileName in fileNames) {
    NSURL* filePath = [shared_favicon_attributes_folder_url
        URLByAppendingPathComponent:fileName
                        isDirectory:NO];
    NSDictionary* fileAttribs =
        [file_manager attributesOfItemAtPath:filePath.path error:nil];
    if (fileAttribs) {
      [favicon_info_dict setObject:fileAttribs[NSFileCreationDate]
                            forKey:fileName];
    }
  }
  return favicon_info_dict;
}

bool ShouldFetchFavicon(NSString* favicon_key,
                        NSDictionary<NSString*, NSDate*>* favicon_dict) {
  if (!favicon_dict) {
    return true;
  }

  // If there is not previous fetch date, it means there is no favicon for that
  // key. Fetch it.
  NSDate* favicon_fetch_date = [favicon_dict valueForKey:favicon_key];
  if (!favicon_fetch_date) {
    return true;
  }

  // Re-fetch the favicon if it's older than the threshold.
  return base::Time::Now() - base::Time::FromNSDate(favicon_fetch_date) >
         kFaviconRefreshInterval;
}

bool DeleteFaviconsFolder() {
  NSURL* shared_favicon_attributes_folder_url =
      app_group::SharedFaviconAttributesFolder();

  NSFileManager* file_manager = [NSFileManager defaultManager];
  NSString* path = shared_favicon_attributes_folder_url.path;

  // If the favicon folder doesn't exist, there's nothing to delete.
  if (![file_manager fileExistsAtPath:path]) {
    return true;
  }

  return [file_manager removeItemAtPath:path error:nil];
}