diff --git a/lib/open_project/cache.rb b/lib/open_project/cache.rb index eb7b4faf3fe..62574b0ba8c 100644 --- a/lib/open_project/cache.rb +++ b/lib/open_project/cache.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/lib/open_project/confidential_cache.rb b/lib/open_project/confidential_cache.rb new file mode 100644 index 00000000000..966c2ea041b --- /dev/null +++ b/lib/open_project/confidential_cache.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + # An encrypting version of OpenProject::Cache. Should be used for caching values that should be kept + # confidential to the application. Especially secrets such as access tokens, passwords an private keys + # should not be cached in plain text, but through this cache accessor. + module ConfidentialCache + class << self + delegate :delete, :clear, to: Cache + + def fetch(*, **) + ciphertext = Cache.fetch(*, **) { token_encryptor.encrypt_and_sign(yield) } + + token_encryptor.decrypt_and_verify(ciphertext) + rescue ActiveSupport::MessageEncryptor::InvalidMessage + # Drop values that can't be read, ensuring the cache heals from unreadable values + delete(*) + retry + end + + def read(name, **) + ciphertext = Cache.read(name, **) + return nil if ciphertext.blank? + + token_encryptor.decrypt_and_verify(ciphertext) + rescue ActiveSupport::MessageEncryptor::InvalidMessage + # Drop values that can't be read, ensuring the cache heals from unreadable values + delete(name) + nil + end + + def write(name, value, **) + ciphertext = token_encryptor.encrypt_and_sign(value) + Cache.write(name, ciphertext, **) + end + + private + + def token_encryptor + @token_encryptor ||= begin + key = Rails.application.key_generator.generate_key("op-cache:confidential-values:v1", 32) + ActiveSupport::MessageEncryptor.new(key, cipher: "aes-256-gcm", serializer: YAML) + end + end + end + end +end diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb index 3df28b04d4c..75d80eeab7b 100644 --- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb +++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb @@ -6,7 +6,7 @@ module OpenProject def self.configuration providers = Saml::Provider.where(available: true) - OpenProject::Cache.fetch(providers.cache_key_with_version) do + OpenProject::ConfidentialCache.fetch(providers.cache_key_with_version) do providers.each_with_object({}) do |provider, hash| hash[provider.slug.to_sym] = provider.to_h end diff --git a/modules/openid_connect/lib/open_project/openid_connect.rb b/modules/openid_connect/lib/open_project/openid_connect.rb index e64deb8e14e..e095c071fe1 100644 --- a/modules/openid_connect/lib/open_project/openid_connect.rb +++ b/modules/openid_connect/lib/open_project/openid_connect.rb @@ -40,7 +40,7 @@ module OpenProject def self.configuration providers = ::OpenIDConnect::Provider.where(available: true) - OpenProject::Cache.fetch(providers.cache_key_with_version) do + OpenProject::ConfidentialCache.fetch(providers.cache_key_with_version) do providers.each_with_object({}) do |provider, hash| hash[provider.slug.to_sym] = provider.to_h end diff --git a/modules/storages/app/common/storages/adapters/authentication_strategies/oauth_client_credentials.rb b/modules/storages/app/common/storages/adapters/authentication_strategies/oauth_client_credentials.rb index 083c36ee3e0..ffba8493df2 100644 --- a/modules/storages/app/common/storages/adapters/authentication_strategies/oauth_client_credentials.rb +++ b/modules/storages/app/common/storages/adapters/authentication_strategies/oauth_client_credentials.rb @@ -43,7 +43,7 @@ module Storages config = validate_configuration(storage).value_or { return Failure(it) } token_cache_key = TOKEN_CACHE_KEY % storage.id - access_token = @use_cache ? Rails.cache.read(token_cache_key) : nil + access_token = @use_cache ? OpenProject::ConfidentialCache.read(token_cache_key) : nil session = build_http_session(access_token, config, http_options).value_or { Failure(it) } @@ -86,10 +86,10 @@ module Storages def write_cache(key, httpx_session) access_token = httpx_session.send(:oauth_session).access_token - Rails.cache.write(key, access_token, expires_in: 50.minutes) + OpenProject::ConfidentialCache.write(key, access_token, expires_in: 50.minutes) end - def clear_cache(key) = Rails.cache.delete(key) + def clear_cache(key) = OpenProject::ConfidentialCache.delete(key) def build_http_session(access_token, config, http_options) Success(OpenProject.httpx.plugin(:oauth) diff --git a/modules/storages/spec/common/storages/adapters/authentication_strategies/oauth_client_credentials_spec.rb b/modules/storages/spec/common/storages/adapters/authentication_strategies/oauth_client_credentials_spec.rb index 42d78317082..0a9637989cf 100644 --- a/modules/storages/spec/common/storages/adapters/authentication_strategies/oauth_client_credentials_spec.rb +++ b/modules/storages/spec/common/storages/adapters/authentication_strategies/oauth_client_credentials_spec.rb @@ -54,7 +54,7 @@ module Storages Authentication[strategy_data].call(storage:) { make_request(it) } cache_key = described_class::TOKEN_CACHE_KEY % storage.id - expect(Rails.cache.read(cache_key)).not_to be_nil + expect(OpenProject::ConfidentialCache.read(cache_key)).not_to be_nil end end diff --git a/spec/lib/open_project/confidential_cache_spec.rb b/spec/lib/open_project/confidential_cache_spec.rb new file mode 100644 index 00000000000..feb1ee18216 --- /dev/null +++ b/spec/lib/open_project/confidential_cache_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe OpenProject::ConfidentialCache do + let(:cache_key) { SecureRandom.uuid } + + describe "#read and #write" do + it "roundtrips" do + expected = "an-expected-value" + described_class.write(cache_key, expected) + actual = described_class.read(cache_key) + + expect(actual).to eq(expected) + end + + it "stores in the same location as OpenProject::Cache" do + described_class.write(cache_key, "something") + + expect(OpenProject::Cache.read(cache_key)).not_to be_nil + end + + it "does not store plain text values" do + plain_text = "an-expected-value" + described_class.write(cache_key, plain_text) + stored_text = OpenProject::Cache.read(cache_key) + + expect(stored_text).not_to eq(plain_text) + end + + it "returns nil when no value has been written" do + expect(described_class.read(cache_key)).to be_nil + end + + it "returns nil when the value is undecryptable" do + OpenProject::Cache.write(cache_key, "some clear text") + expect(described_class.read(cache_key)).to be_nil + end + end + + describe "#fetch" do + it "returns block results if uncached" do + values = %w[first second] + result = described_class.fetch(cache_key) { values.shift } + + expect(result).to eq("first") + end + + it "returns cached result if cached" do + values = %w[first second] + described_class.fetch(cache_key) { values.shift } + result = described_class.fetch(cache_key) { values.shift } + + expect(result).to eq("first") + end + + it "returns block results if value is undecryptable" do + values = %w[first second] + OpenProject::Cache.write(cache_key, "some clear text") + result = described_class.fetch(cache_key) { values.shift } + + expect(result).to eq("first") + end + + it "stores block results in the same location as OpenProject::Cache" do + values = %w[first second] + described_class.fetch(cache_key) { values.shift } + + expect(OpenProject::Cache.read(cache_key)).not_to be_nil + end + end +end