From a78d5659593d9fcce02a24a95c0ca0d111706465 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Lang Date: Sat, 26 Feb 2011 13:09:25 +0000 Subject: [PATCH] Adds support for SCM/LDAP passwords encryption in the database (#7411). git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@4950 e93f8b46-1217-0410-a6f0-8f06a7374b81 --- app/models/auth_source.rb | 10 ++ app/models/auth_source_ldap.rb | 4 +- app/models/repository.rb | 11 +++ config/configuration.yml.example | 14 +++ ...0112_change_repositories_password_limit.rb | 9 ++ ...nge_auth_sources_account_password_limit.rb | 9 ++ lib/redmine/ciphering.rb | 95 +++++++++++++++++++ lib/tasks/ciphering.rake | 35 +++++++ test/unit/lib/redmine/ciphering_test.rb | 84 ++++++++++++++++ 9 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20110226120112_change_repositories_password_limit.rb create mode 100644 db/migrate/20110226120132_change_auth_sources_account_password_limit.rb create mode 100644 lib/redmine/ciphering.rb create mode 100644 lib/tasks/ciphering.rake create mode 100644 test/unit/lib/redmine/ciphering_test.rb diff --git a/app/models/auth_source.rb b/app/models/auth_source.rb index 84f17b1bc..e9922ca56 100644 --- a/app/models/auth_source.rb +++ b/app/models/auth_source.rb @@ -16,6 +16,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class AuthSource < ActiveRecord::Base + include Redmine::Ciphering + has_many :users validates_presence_of :name @@ -31,6 +33,14 @@ class AuthSource < ActiveRecord::Base def auth_method_name "Abstract" end + + def account_password + read_ciphered_attribute(:account_password) + end + + def account_password=(arg) + write_ciphered_attribute(:account_password, arg) + end def allow_password_changes? self.class.allow_password_changes? diff --git a/app/models/auth_source_ldap.rb b/app/models/auth_source_ldap.rb index d2a7e7041..6b23c670c 100644 --- a/app/models/auth_source_ldap.rb +++ b/app/models/auth_source_ldap.rb @@ -20,8 +20,8 @@ require 'iconv' class AuthSourceLdap < AuthSource validates_presence_of :host, :port, :attr_login - validates_length_of :name, :host, :account_password, :maximum => 60, :allow_nil => true - validates_length_of :account, :base_dn, :maximum => 255, :allow_nil => true + validates_length_of :name, :host, :maximum => 60, :allow_nil => true + validates_length_of :account, :account_password, :base_dn, :maximum => 255, :allow_nil => true validates_length_of :attr_login, :attr_firstname, :attr_lastname, :attr_mail, :maximum => 30, :allow_nil => true validates_numericality_of :port, :only_integer => true diff --git a/app/models/repository.rb b/app/models/repository.rb index c9d7d0dbe..075ecbe34 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -16,6 +16,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class Repository < ActiveRecord::Base + include Redmine::Ciphering + belongs_to :project has_many :changesets, :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC" has_many :changes, :through => :changesets @@ -24,6 +26,7 @@ class Repository < ActiveRecord::Base # has_many :changesets, :dependent => :destroy is too slow for big repositories before_destroy :clear_changesets + validates_length_of :password, :maximum => 255, :allow_nil => true # Checks if the SCM is enabled when creating a repository validate_on_create { |r| r.errors.add(:type, :invalid) unless Setting.enabled_scm.include?(r.class.name.demodulize) } @@ -36,6 +39,14 @@ class Repository < ActiveRecord::Base def root_url=(arg) write_attribute(:root_url, arg ? arg.to_s.strip : nil) end + + def password + read_ciphered_attribute(:password) + end + + def password=(arg) + write_ciphered_attribute(:password, arg) + end def scm_adapter self.class.scm_adapter_class diff --git a/config/configuration.yml.example b/config/configuration.yml.example index 51030fe90..a729c60c7 100644 --- a/config/configuration.yml.example +++ b/config/configuration.yml.example @@ -124,6 +124,20 @@ default: scm_bazaar_command: scm_darcs_command: + # Key used to encrypt sensitive data in the database (SCM and LDAP passwords). + # If you don't want to enable data encryption, just leave it blank. + # WARNING: losing/changing this key will make encrypted data unreadable. + # + # If you want to encrypt existing passwords in your database: + # * set the cipher key here in your configuration file + # * encrypt data using 'rake db:encrypt RAILS_ENV=production' + # + # If you have encrypted data and want to change this key, you have to: + # * decrypt data using 'rake db:decrypt RAILS_ENV=production' first + # * change the cipher key here in your configuration file + # * encrypt data using 'rake db:encrypt RAILS_ENV=production' + database_cipher_key: + # specific configuration options for production environment # that overrides the default ones production: diff --git a/db/migrate/20110226120112_change_repositories_password_limit.rb b/db/migrate/20110226120112_change_repositories_password_limit.rb new file mode 100644 index 000000000..1ad937c7d --- /dev/null +++ b/db/migrate/20110226120112_change_repositories_password_limit.rb @@ -0,0 +1,9 @@ +class ChangeRepositoriesPasswordLimit < ActiveRecord::Migration + def self.up + change_column :repositories, :password, :string, :limit => nil, :default => '' + end + + def self.down + change_column :repositories, :password, :string, :limit => 60, :default => '' + end +end diff --git a/db/migrate/20110226120132_change_auth_sources_account_password_limit.rb b/db/migrate/20110226120132_change_auth_sources_account_password_limit.rb new file mode 100644 index 000000000..b1cd80aa5 --- /dev/null +++ b/db/migrate/20110226120132_change_auth_sources_account_password_limit.rb @@ -0,0 +1,9 @@ +class ChangeAuthSourcesAccountPasswordLimit < ActiveRecord::Migration + def self.up + change_column :auth_sources, :account_password, :string, :limit => nil, :default => '' + end + + def self.down + change_column :auth_sources, :account_password, :string, :limit => 60, :default => '' + end +end diff --git a/lib/redmine/ciphering.rb b/lib/redmine/ciphering.rb new file mode 100644 index 000000000..8efe37c29 --- /dev/null +++ b/lib/redmine/ciphering.rb @@ -0,0 +1,95 @@ +# Redmine - project management software +# Copyright (C) 2006-2011 Jean-Philippe Lang +# +# 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. + +module Redmine + module Ciphering + def self.included(base) + base.extend ClassMethods + end + + class << self + def encrypt_text(text) + if cipher_key.blank? + text + else + c = OpenSSL::Cipher::Cipher.new("aes-256-cbc") + iv = c.random_iv + c.encrypt + c.key = cipher_key + c.iv = iv + e = c.update(text.to_s) + e << c.final + "aes-256-cbc:" + [e, iv].map {|v| Base64.encode64(v).strip}.join('--') + end + end + + def decrypt_text(text) + if text && match = text.match(/\Aaes-256-cbc:(.+)\Z/) + text = match[1] + c = OpenSSL::Cipher::Cipher.new("aes-256-cbc") + e, iv = text.split("--").map {|s| Base64.decode64(s)} + c.decrypt + c.key = cipher_key + c.iv = iv + d = c.update(e) + d << c.final + else + text + end + end + + def cipher_key + key = Redmine::Configuration['database_cipher_key'].to_s + key.blank? ? nil : Digest::SHA256.hexdigest(key) + end + end + + module ClassMethods + def encrypt_all(attribute) + transaction do + all.each do |object| + clear = object.send(attribute) + object.send "#{attribute}=", clear + raise(ActiveRecord::Rollback) unless object.save(false) + end + end ? true : false + end + + def decrypt_all(attribute) + transaction do + all.each do |object| + clear = object.send(attribute) + object.write_attribute attribute, clear + raise(ActiveRecord::Rollback) unless object.save(false) + end + end + end ? true : false + end + + private + + # Returns the value of the given ciphered attribute + def read_ciphered_attribute(attribute) + Redmine::Ciphering.decrypt_text(read_attribute(attribute)) + end + + # Sets the value of the given ciphered attribute + def write_ciphered_attribute(attribute, value) + write_attribute(attribute, Redmine::Ciphering.encrypt_text(value)) + end + end +end diff --git a/lib/tasks/ciphering.rake b/lib/tasks/ciphering.rake new file mode 100644 index 000000000..fec6f4680 --- /dev/null +++ b/lib/tasks/ciphering.rake @@ -0,0 +1,35 @@ +# Redmine - project management software +# Copyright (C) 2006-2011 Jean-Philippe Lang +# +# 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. + + +namespace :db do + desc 'Encrypts SCM and LDAP passwords in the database.' + task :encrypt => :environment do + unless (Repository.encrypt_all(:password) && + AuthSource.encrypt_all(:account_password)) + raise "Some objects could not be saved after encryption, update was rollback'ed." + end + end + + desc 'Decrypts SCM and LDAP passwords in the database.' + task :decrypt => :environment do + unless (Repository.decrypt_all(:password) && + AuthSource.decrypt_all(:account_password)) + raise "Some objects could not be saved after decryption, update was rollback'ed." + end + end +end diff --git a/test/unit/lib/redmine/ciphering_test.rb b/test/unit/lib/redmine/ciphering_test.rb new file mode 100644 index 000000000..5af5f7114 --- /dev/null +++ b/test/unit/lib/redmine/ciphering_test.rb @@ -0,0 +1,84 @@ +# Redmine - project management software +# Copyright (C) 2006-2011 Jean-Philippe Lang +# +# 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. + +require File.expand_path('../../../../test_helper', __FILE__) + +class Redmine::CipheringTest < ActiveSupport::TestCase + + def test_password_should_be_encrypted + Redmine::Configuration.with 'database_cipher_key' => 'secret' do + r = Repository::Subversion.generate!(:password => 'foo') + assert_equal 'foo', r.password + assert r.read_attribute(:password).match(/\Aaes-256-cbc:.+\Z/) + end + end + + def test_password_should_be_clear_with_blank_key + Redmine::Configuration.with 'database_cipher_key' => '' do + r = Repository::Subversion.generate!(:password => 'foo') + assert_equal 'foo', r.password + assert_equal 'foo', r.read_attribute(:password) + end + end + + def test_password_should_be_clear_with_nil_key + Redmine::Configuration.with 'database_cipher_key' => nil do + r = Repository::Subversion.generate!(:password => 'foo') + assert_equal 'foo', r.password + assert_equal 'foo', r.read_attribute(:password) + end + end + + def test_unciphered_password_should_be_readable + Redmine::Configuration.with 'database_cipher_key' => nil do + r = Repository::Subversion.generate!(:password => 'clear') + end + + Redmine::Configuration.with 'database_cipher_key' => 'secret' do + r = Repository.first(:order => 'id DESC') + assert_equal 'clear', r.password + end + end + + def test_encrypt_all + Repository.delete_all + Redmine::Configuration.with 'database_cipher_key' => nil do + Repository::Subversion.generate!(:password => 'foo') + Repository::Subversion.generate!(:password => 'bar') + end + + Redmine::Configuration.with 'database_cipher_key' => 'secret' do + assert Repository.encrypt_all(:password) + r = Repository.first(:order => 'id DESC') + assert_equal 'bar', r.password + assert r.read_attribute(:password).match(/\Aaes-256-cbc:.+\Z/) + end + end + + def test_decrypt_all + Repository.delete_all + Redmine::Configuration.with 'database_cipher_key' => 'secret' do + Repository::Subversion.generate!(:password => 'foo') + Repository::Subversion.generate!(:password => 'bar') + + assert Repository.decrypt_all(:password) + r = Repository.first(:order => 'id DESC') + assert_equal 'bar', r.password + assert_equal 'bar', r.read_attribute(:password) + end + end +end -- 2.39.5