| // Copyright 2024 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "net/device_bound_sessions/session_store_impl.h" |
| |
| #include <algorithm> |
| #include <optional> |
| |
| #include "base/containers/map_util.h" |
| #include "base/containers/span.h" |
| #include "base/debug/dump_without_crashing.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "base/process/process.h" |
| #include "base/sequence_checker.h" |
| #include "base/strings/string_view_util.h" |
| #include "base/task/sequenced_task_runner.h" |
| #include "base/task/thread_pool.h" |
| #include "base/time/time.h" |
| #include "base/types/expected_macros.h" |
| #include "components/unexportable_keys/background_task_priority.h" |
| #include "components/unexportable_keys/features.h" |
| #include "components/unexportable_keys/service_error.h" |
| #include "components/unexportable_keys/unexportable_key_id.h" |
| #include "components/unexportable_keys/unexportable_key_service.h" |
| #include "net/base/features.h" |
| #include "net/base/schemeful_site.h" |
| #include "net/device_bound_sessions/deletion_reason.h" |
| #include "net/device_bound_sessions/proto/storage.pb.h" |
| |
| namespace net::device_bound_sessions { |
| |
| namespace { |
| |
| using unexportable_keys::BackgroundTaskPriority; |
| using unexportable_keys::ServiceError; |
| using unexportable_keys::ServiceErrorOr; |
| using unexportable_keys::UnexportableKeyService; |
| using unexportable_keys::UnexportableSigningKeyId; |
| |
| // Priority is set to `USER_VISIBLE` because the initial load of |
| // sessions from disk is required to complete before URL requests |
| // can be checked to see if they are associated with bound sessions. |
| constexpr base::TaskTraits kDBTaskTraits = { |
| base::MayBlock(), base::TaskPriority::USER_VISIBLE, |
| base::TaskShutdownBehavior::BLOCK_SHUTDOWN}; |
| |
| const char kSessionTableName[] = "dbsc_session_tbl"; |
| const base::TimeDelta kFlushDelay = base::Seconds(2); |
| |
| // The delay between when the session service is loaded and the garbage |
| // collection is started. This is delayed to not slow down the startup of the |
| // browser. |
| constexpr base::TimeDelta kGarbageCollectionDelay = base::Minutes(2); |
| |
| // Histogram name for the garbage collection of unexportable keys. |
| constexpr std::string_view kGarbageCollectionHistogramPrefix = |
| "Crypto.UnexportableKeys.GarbageCollection.DeviceBoundSessions."; |
| |
| SessionStoreImpl::DBStatus InitializeOnDbSequence( |
| sql::Database* db, |
| base::FilePath db_storage_path, |
| sqlite_proto::ProtoTableManager* table_manager, |
| sqlite_proto::KeyValueData<proto::SiteSessions>* session_data) { |
| if (db->Open(db_storage_path) == false) { |
| return SessionStoreImpl::DBStatus::kFailure; |
| } |
| |
| // Control the schema version with a feature param so that the database can be |
| // wiped between Origin Trials and going into the final release. |
| table_manager->InitializeOnDbSequence( |
| db, std::vector<std::string>{kSessionTableName}, |
| features::kDeviceBoundSessionsSchemaVersion.Get()); |
| session_data->InitializeOnDBSequence(); |
| |
| return SessionStoreImpl::DBStatus::kSuccess; |
| } |
| |
| } // namespace |
| |
| SessionStoreImpl::SessionStoreImpl(base::FilePath db_storage_path, |
| UnexportableKeyService& key_service) |
| : key_service_(key_service), |
| db_task_runner_( |
| base::ThreadPool::CreateSequencedTaskRunner(kDBTaskTraits)), |
| db_storage_path_(std::move(db_storage_path)), |
| db_(std::make_unique<sql::Database>(sql::DatabaseOptions(), |
| sql::Database::Tag("DBSCSessions"))), |
| table_manager_(base::MakeRefCounted<sqlite_proto::ProtoTableManager>( |
| db_task_runner_)), |
| session_table_( |
| std::make_unique<sqlite_proto::KeyValueTable<proto::SiteSessions>>( |
| kSessionTableName)), |
| session_data_( |
| std::make_unique<sqlite_proto::KeyValueData<proto::SiteSessions>>( |
| table_manager_, |
| session_table_.get(), |
| /*max_num_entries=*/std::nullopt, |
| kFlushDelay)) {} |
| |
| SessionStoreImpl::~SessionStoreImpl() { |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| |
| if (db_status_ == DBStatus::kSuccess) { |
| session_data_->FlushDataToDisk(); |
| } |
| |
| // Shutdown `table_manager_`, and delete it together with `db_` |
| // and KeyValueTable on DB sequence, then delete the KeyValueData |
| // and call `shutdown_callback_` on main sequence. |
| // This ensures that DB objects outlive any other task posted to DB |
| // sequence, since their deletion is the very last posted task. |
| db_task_runner_->PostTaskAndReply( |
| FROM_HERE, |
| base::BindOnce( |
| [](scoped_refptr<sqlite_proto::ProtoTableManager> table_manager, |
| std::unique_ptr<sql::Database> db, |
| auto session_table) { table_manager->WillShutdown(); }, |
| std::move(table_manager_), std::move(db_), std::move(session_table_)), |
| base::BindOnce( |
| [](auto session_data, base::OnceClosure shutdown_callback) { |
| if (shutdown_callback) { |
| std::move(shutdown_callback).Run(); |
| } |
| }, |
| std::move(session_data_), std::move(shutdown_callback_))); |
| } |
| |
| void SessionStoreImpl::LoadSessions(LoadSessionsCallback callback) { |
| CHECK_EQ(db_status_, DBStatus::kNotLoaded); |
| |
| // This is safe because tasks are serialized on the db_task_runner sequence |
| // and the `table_manager_` and `session_data_` are only freed after a |
| // response from a task (triggered by the destructor) runs on the |
| // `db_task_runner_`. |
| // Similarly, the `db_` is not actually destroyed until the task |
| // triggered by the destructor runs on the `db_task_runner_`. |
| db_task_runner_->PostTaskAndReplyWithResult( |
| FROM_HERE, |
| base::BindOnce(&InitializeOnDbSequence, base::Unretained(db_.get()), |
| db_storage_path_, base::Unretained(table_manager_.get()), |
| base::Unretained(session_data_.get())), |
| base::BindOnce(&SessionStoreImpl::OnDatabaseLoaded, |
| weak_ptr_factory_.GetWeakPtr(), std::move(callback), |
| base::ElapsedTimer())); |
| } |
| |
| void SessionStoreImpl::OnDatabaseLoaded(LoadSessionsCallback callback, |
| base::ElapsedTimer timer, |
| DBStatus db_status) { |
| db_status_ = db_status; |
| SessionsMap sessions; |
| if (db_status == DBStatus::kSuccess) { |
| std::vector<std::string> keys_to_delete; |
| std::map<std::string, proto::SiteSessions> sites_to_update; |
| sessions = CreateSessionsFromLoadedData(session_data_->GetAllCached(), |
| keys_to_delete, sites_to_update, |
| /*prune_expired_sessions=*/true); |
| if (!keys_to_delete.empty()) { |
| session_data_->DeleteData(keys_to_delete); |
| } |
| for (const auto& [site_str, site_proto] : sites_to_update) { |
| session_data_->UpdateData(site_str, site_proto); |
| } |
| |
| // Schedule a task for original profiles to obtain all keys that were |
| // created for this profile in the past, including all OTR profiles. |
| if (base::FeatureList::IsEnabled( |
| unexportable_keys::kUnexportableKeyDeletion)) { |
| base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask( |
| FROM_HERE, |
| base::BindOnce(&SessionStoreImpl::StartGarbageCollection, |
| weak_ptr_factory_.GetWeakPtr()), |
| kGarbageCollectionDelay); |
| } |
| } |
| |
| base::UmaHistogramBoolean("Net.DeviceBoundSessions.SessionStoreLoadSuccess", |
| db_status == DBStatus::kSuccess); |
| base::UmaHistogramTimes("Net.DeviceBoundSessions.SessionStoreLoadDuration", |
| timer.Elapsed()); |
| std::move(callback).Run(std::move(sessions)); |
| } |
| |
| // static |
| SessionStore::SessionsMap SessionStoreImpl::CreateSessionsFromLoadedData( |
| const std::map<std::string, proto::SiteSessions>& loaded_data, |
| std::vector<std::string>& keys_to_delete, |
| std::map<std::string, proto::SiteSessions>& sites_to_update, |
| bool prune_expired_sessions) { |
| SessionsMap all_sessions; |
| for (const auto& [site_str, site_proto] : loaded_data) { |
| SchemefulSite site = net::SchemefulSite::Deserialize(site_str); |
| if (site.opaque()) { |
| keys_to_delete.push_back(site_str); |
| continue; |
| } |
| |
| SessionsMap site_sessions; |
| std::vector<std::string> session_ids_to_prune; |
| for (const auto& [session_id, session_proto] : site_proto.sessions()) { |
| auto session_or_error = Session::CreateFromProto( |
| session_proto, /*check_expiry=*/prune_expired_sessions); |
| if (!session_or_error.has_value()) { |
| LogSessionDeletionReason(session_or_error.error()); |
| session_ids_to_prune.push_back(session_id); |
| continue; |
| } |
| |
| std::unique_ptr<Session> session = std::move(session_or_error.value()); |
| if (session->id().value() != session_id) { |
| // TODO(crbug.com/552483536): Replace with session pruning once we |
| // verify whether this discrepancy occurs in the wild. |
| base::debug::DumpWithoutCrashing(); |
| } |
| |
| // Session is structurally valid and unexpired. |
| site_sessions.emplace(SessionKey{site, session->id()}, |
| std::move(session)); |
| } |
| |
| // If no valid sessions remain for this site, remove the entire site entry |
| // from the DB. Otherwise, update the DB entry if some sessions were pruned. |
| if (site_sessions.empty()) { |
| keys_to_delete.push_back(site_str); |
| } else { |
| if (!session_ids_to_prune.empty()) { |
| proto::SiteSessions updated_site_proto = site_proto; |
| for (const std::string& invalid_id : session_ids_to_prune) { |
| updated_site_proto.mutable_sessions()->erase(invalid_id); |
| } |
| sites_to_update[site_str] = std::move(updated_site_proto); |
| } |
| all_sessions.merge(site_sessions); |
| } |
| } |
| |
| return all_sessions; |
| } |
| |
| void SessionStoreImpl::SetShutdownCallbackForTesting( |
| base::OnceClosure shutdown_callback) { |
| shutdown_callback_ = std::move(shutdown_callback); |
| } |
| |
| void SessionStoreImpl::SaveSession(const SchemefulSite& site, |
| const Session& session, |
| SessionStore::SaveSessionMode mode) { |
| if (db_status_ != DBStatus::kSuccess) { |
| return; |
| } |
| |
| CHECK(session.unexportable_key_id().has_value()); |
| |
| // Wrap the unexportable key into a persistable form. |
| ServiceErrorOr<std::vector<uint8_t>> wrapped_key = |
| key_service_->GetWrappedKey(*session.unexportable_key_id()); |
| // Don't bother persisting the session if wrapping fails because we will throw |
| // away all persisted data if the wrapped key is missing for any session. |
| if (!wrapped_key.has_value()) { |
| return; |
| } |
| |
| proto::Session session_proto = session.ToProto(); |
| session_proto.set_wrapped_key( |
| std::string(wrapped_key->begin(), wrapped_key->end())); |
| |
| // Handle attestation key if present. |
| AttestationKeySaveOutcome outcome = |
| SetWrappedAttestationKey(site, session, session_proto, mode); |
| base::UmaHistogramEnumeration( |
| "Net.DeviceBoundSessions.AttestationKeySaveOutcome", outcome); |
| |
| proto::SiteSessions site_proto; |
| std::string site_str = site.Serialize(); |
| session_data_->TryGetData(site_str, &site_proto); |
| (*site_proto.mutable_sessions())[session_proto.id()] = |
| std::move(session_proto); |
| |
| session_data_->UpdateData(site_str, site_proto); |
| } |
| |
| SessionStoreImpl::AttestationKeySaveOutcome |
| SessionStoreImpl::SetWrappedAttestationKey(const SchemefulSite& site, |
| const Session& session, |
| proto::Session& session_proto, |
| SessionStore::SaveSessionMode mode) { |
| const auto& maybe_aik_id_or_error = |
| session.maybe_unexportable_attestation_key_id(); |
| |
| // The in-memory session indicates the attestation key is not yet loaded into |
| // the TPM by returning `ServiceError::kKeyNotReady`. |
| // |
| // During a session refresh (`kRefresh`), the refreshed session is expected |
| // to reuse the same attestation key. Since loading it is an expensive |
| // operation, we delay loading it until it is actually needed, and in the |
| // meantime, we preserve the existing wrapped key by copying it from the |
| // database entry of the old session. |
| // |
| // If this is a new session (`kNewSession`), key preservation is disabled to |
| // avoid leaking a key between two independent sessions. |
| if (mode == SessionStore::SaveSessionMode::kRefresh && |
| maybe_aik_id_or_error == base::unexpected(ServiceError::kKeyNotReady)) { |
| proto::SiteSessions old_site_proto; |
| if (!session_data_->TryGetData(site.Serialize(), &old_site_proto)) { |
| return AttestationKeySaveOutcome::kKeyNotReadyNoSiteInDb; |
| } |
| |
| const proto::Session* old_session = |
| base::FindOrNull(old_site_proto.sessions(), *session.id()); |
| if (!old_session || !old_session->has_wrapped_attestation_key()) { |
| return old_session ? AttestationKeySaveOutcome::kKeyNotReadyNoOldKeyToCopy |
| : AttestationKeySaveOutcome::kKeyNotReadyNoSessionInDb; |
| } |
| |
| session_proto.set_wrapped_attestation_key( |
| old_session->wrapped_attestation_key()); |
| return AttestationKeySaveOutcome::kKeyNotReadyCopiedOldKey; |
| } |
| |
| // Unexpected error (e.g. kFailure or kKeyNotFound). |
| ASSIGN_OR_RETURN( |
| std::optional<unexportable_keys::UnexportableAttestationKeyId> |
| maybe_aik_id, |
| maybe_aik_id_or_error, |
| [](auto) { return AttestationKeySaveOutcome::kUnexpectedError; }); |
| |
| // No key is expected (nullopt). Do not set it in the proto (clearing it). |
| if (!maybe_aik_id) { |
| session_proto.clear_wrapped_attestation_key(); |
| return AttestationKeySaveOutcome::kNoAttestationKey; |
| } |
| |
| // Wrap the attestation key and save it. |
| ASSIGN_OR_RETURN(std::vector<uint8_t> wrapped_attestation_key, |
| key_service_->GetWrappedKey(*maybe_aik_id), [](auto) { |
| return AttestationKeySaveOutcome::kGetWrappedKeyFailure; |
| }); |
| |
| session_proto.set_wrapped_attestation_key( |
| base::as_string_view(wrapped_attestation_key)); |
| return AttestationKeySaveOutcome::kSaveSessionKeySuccess; |
| } |
| |
| void SessionStoreImpl::DeleteSession(const SessionKey& key) { |
| if (db_status_ != DBStatus::kSuccess) { |
| return; |
| } |
| |
| proto::SiteSessions site_proto; |
| std::string site_str = key.site.Serialize(); |
| if (!session_data_->TryGetData(site_str, &site_proto)) { |
| return; |
| } |
| |
| if (site_proto.sessions().count(*key.id) == 0) { |
| return; |
| } |
| |
| // If this is the only session associated with the site, |
| // delete the site entry. |
| if (site_proto.mutable_sessions()->size() == 1) { |
| session_data_->DeleteData({site_str}); |
| return; |
| } |
| |
| site_proto.mutable_sessions()->erase(*key.id); |
| |
| // Schedule a DB update for the site entry. |
| session_data_->UpdateData(key.site.Serialize(), site_proto); |
| } |
| |
| SessionStore::SessionsMap SessionStoreImpl::GetAllSessions() const { |
| if (db_status_ != DBStatus::kSuccess) { |
| return SessionsMap(); |
| } |
| |
| // We shouldn't find invalid keys at this point, they should have all been |
| // filtered out in the `LoadSessions` operations. So, all session entries in |
| // the cache are expected to be valid. |
| std::vector<std::string> keys_to_delete; |
| std::map<std::string, proto::SiteSessions> sites_to_update; |
| SessionsMap all_sessions = CreateSessionsFromLoadedData( |
| session_data_->GetAllCached(), keys_to_delete, sites_to_update, |
| /*prune_expired_sessions=*/false); |
| CHECK(keys_to_delete.empty()); |
| CHECK(sites_to_update.empty()); |
| return all_sessions; |
| } |
| |
| std::optional<proto::Session> SessionStoreImpl::GetSessionProto( |
| const SessionKey& session_key) const { |
| if (db_status_ != DBStatus::kSuccess) { |
| return std::nullopt; |
| } |
| |
| proto::SiteSessions site_proto; |
| if (!session_data_->TryGetData(session_key.site.Serialize(), &site_proto)) { |
| return std::nullopt; |
| } |
| |
| proto::Session* session = |
| base::FindOrNull(*site_proto.mutable_sessions(), *session_key.id); |
| return session ? std::optional(std::move(*session)) : std::nullopt; |
| } |
| |
| void SessionStoreImpl::RestoreSessionBindingKey( |
| const SessionKey& session_key, |
| unexportable_keys::BackgroundTaskPriority priority, |
| RestoreSessionBindingKeyCallback callback) { |
| std::optional<proto::Session> session_proto = GetSessionProto(session_key); |
| session_proto ? key_service_->FromWrappedSigningKeySlowlyAsync( |
| base::as_byte_span(session_proto->wrapped_key()), |
| priority, std::move(callback)) |
| : std::move(callback).Run(base::unexpected( |
| unexportable_keys::ServiceError::kKeyNotFound)); |
| } |
| |
| void SessionStoreImpl::RestoreSessionAttestationKey( |
| const SessionKey& session_key, |
| unexportable_keys::BackgroundTaskPriority priority, |
| RestoreSessionAttestationKeyCallback callback) { |
| std::optional<proto::Session> session_proto = GetSessionProto(session_key); |
| (session_proto && session_proto->has_wrapped_attestation_key()) |
| ? key_service_->FromWrappedAttestationKeySlowlyAsync( |
| base::as_byte_span(session_proto->wrapped_attestation_key()), |
| priority, std::move(callback)) |
| : std::move(callback).Run( |
| base::unexpected(unexportable_keys::ServiceError::kKeyNotFound)); |
| } |
| |
| void SessionStoreImpl::StartGarbageCollection() { |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| |
| CHECK_EQ(db_status_, DBStatus::kSuccess); |
| key_service_->GetAllKeysForGarbageCollectionSlowlyAsync( |
| unexportable_keys::BackgroundTaskPriority::kBestEffort, |
| base::BindOnce(&SessionStoreImpl::OnGetAllKeysForGarbageCollection, |
| weak_ptr_factory_.GetWeakPtr())); |
| } |
| |
| void SessionStoreImpl::OnGetAllKeysForGarbageCollection( |
| unexportable_keys::ServiceErrorOr< |
| std::vector<unexportable_keys::UnexportableSigningKeyId>> |
| all_key_ids_or_error) { |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| if (!all_key_ids_or_error.has_value() || all_key_ids_or_error->empty()) { |
| return; |
| } |
| |
| absl::flat_hash_set<std::vector<uint8_t>> known_wrapped_keys; |
| for (const auto& [_, site_sessions] : session_data_->GetAllCached()) { |
| for (const auto& [_, session_proto] : site_sessions.sessions()) { |
| if (std::string_view wrapped_key = session_proto.wrapped_key(); |
| !wrapped_key.empty()) { |
| known_wrapped_keys.emplace(std::from_range, wrapped_key); |
| } |
| if (std::string_view wrapped_attestation_key = |
| session_proto.wrapped_attestation_key(); |
| !wrapped_attestation_key.empty()) { |
| known_wrapped_keys.emplace(std::from_range, wrapped_attestation_key); |
| } |
| } |
| } |
| |
| std::vector<unexportable_keys::UnexportableSigningKeyId> all_key_ids = |
| *std::move(all_key_ids_or_error); |
| |
| const size_t key_count = all_key_ids.size(); |
| base::UmaHistogramCounts100( |
| base::StrCat({kGarbageCollectionHistogramPrefix, "TotalKeyCount"}), |
| key_count); |
| |
| // Don't garbage collect keys that are still used, or were created after the |
| // process started. |
| std::erase_if( |
| all_key_ids, [&](unexportable_keys::UnexportableSigningKeyId key_id) { |
| return known_wrapped_keys.contains( |
| key_service_->GetWrappedKey(key_id).value_or({})) || |
| key_service_->GetCreationTime(key_id).value_or( |
| base::Time::Now()) >= |
| base::Process::Current().CreationTime(); |
| }); |
| |
| base::UmaHistogramCounts100( |
| base::StrCat({kGarbageCollectionHistogramPrefix, "UsedKeyCount"}), |
| key_count - all_key_ids.size()); |
| |
| base::UmaHistogramCounts100( |
| base::StrCat({kGarbageCollectionHistogramPrefix, "ObsoleteKeyCount"}), |
| all_key_ids.size()); |
| |
| // Delete all remaining keys. |
| key_service_->DeleteKeysSlowlyAsync( |
| all_key_ids, unexportable_keys::BackgroundTaskPriority::kBestEffort, |
| base::BindOnce([](unexportable_keys::ServiceErrorOr<size_t> result) { |
| base::UmaHistogramCounts100( |
| base::StrCat({kGarbageCollectionHistogramPrefix, |
| "ObsoleteKeyDeletionCount"}), |
| result.value_or(0)); |
| })); |
| } |
| |
| } // namespace net::device_bound_sessions |