rails_credentials_cipher 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 412dc01065aa8cd5f4fde42a2ee138a9d16309feee95cc8f662b83cead22804d
4
+ data.tar.gz: 20bd9b1fe47fb6f5fbe266e79a84cb08aed3ae06bae71ea83ecb17bd72cf5cb2
5
+ SHA512:
6
+ metadata.gz: 96e844c86ab106d790a1ec44117581e0dd1418a8b14bd8de9e60afd4125fba385264b0cd5bc62617c61f693e4e1fcbde39e8a0334eedd9887108812beaea964e
7
+ data.tar.gz: d1519c0a71b45fa7422822f7840ba0ed2b1ca5847b9c5dd43fc293de556622834b0872a9b8534e3a0b1ceaa482296cb0718764a894db4ba60e1b71d5ec6c08a5
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,21 @@
1
+ plugins:
2
+ - rubocop-on-rbs
3
+ - rubocop-rake
4
+ - rubocop-rspec
5
+
6
+ AllCops:
7
+ # The oldest Ruby the gem supports (required_ruby_version), not the one in .tool-versions
8
+ TargetRubyVersion: 3.3
9
+ NewCops: enable
10
+
11
+ # Do not enforce documentation
12
+ Style/Documentation:
13
+ Enabled: false
14
+
15
+ # A round trip is one behaviour with a return value, a file on disk and a message
16
+ RSpec/MultipleExpectations:
17
+ Max: 3
18
+
19
+ # A round trip needs its setup, the call and the check on disk
20
+ RSpec/ExampleLength:
21
+ Enabled: false
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 4.0.5
data/AGENTS.md ADDED
@@ -0,0 +1,27 @@
1
+ # RailsCredentialsCipher
2
+
3
+ @README.md
4
+
5
+ A gem that adds `credentials:decrypt` and `credentials:encrypt` rake tasks to Rails applications.
6
+
7
+ ## Instructions
8
+
9
+ - MUST pass `bundle exec rspec`, `bundle exec rubocop` and `bundle exec steep check` before finishing. MUST NOT disable a cop or a Steep diagnostic without the reason next to it.
10
+ - MUST NOT print or log decrypted content; messages name paths only.
11
+ - Specs MUST NOT need a Rails application; `stub_rails` in `spec/spec_helper.rb` stands in for it.
12
+ - `sig/rails_credentials_cipher.rbs` types the gem and `sig/external/` the slice of Rails it calls; Steep checks `lib/` against both, so a method is not done until its signature is. `sig/external/` is not shipped with the gem.
13
+ - Ruby floor is the oldest version still maintained (3.3): `required_ruby_version`, `TargetRubyVersion` and the CI matrix move together, and `TargetRubyVersion` stays below `.tool-versions` on purpose.
14
+
15
+ ## Development
16
+
17
+ ```sh
18
+ mise install && bin/setup # Ruby from .tool-versions, then bundle install
19
+ bundle exec rspec # tests; add a path or path:line for one file or example
20
+ bundle exec rubocop -A # lint with auto-fix, RBS files included
21
+ bundle exec steep check # types: lib/ against sig/
22
+ bundle exec bundler-audit check --update # dependency audit
23
+ ```
24
+
25
+ To try it in a Rails application, add `gem "rails_credentials_cipher", path: "../rails_credentials_cipher", group: :development` to its Gemfile and run `bin/rails credentials:decrypt` there with its `config/master.key` present.
26
+
27
+ Release: bump `lib/rails_credentials_cipher/version.rb`, `bundle install` so `Gemfile.lock` follows, commit, then `bundle exec rake release`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 thisismydesign
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # RailsCredentialsCipher
2
+
3
+ #### Decrypt, edit, and encrypt Rails credentials.
4
+
5
+ Rails keeps your credentials encrypted and only lets you change them through `bin/rails credentials:edit`, which opens them in a terminal editor. This gem lets you take them out as a plain file, edit that file with whatever you like — your IDE, a coding agent, a script — and encrypt it back.
6
+
7
+ ## Installation
8
+
9
+ Add to your application's Gemfile:
10
+
11
+ ```rb
12
+ gem "rails_credentials_cipher", group: :development
13
+ ```
14
+
15
+ Then keep the decrypted files out of git:
16
+
17
+ ```sh
18
+ bin/rails generate rails_credentials_cipher:install
19
+ ```
20
+
21
+ This adds to `.gitignore`:
22
+
23
+ ```gitignore
24
+ /config/credentials.yml
25
+ /config/credentials/*.yml
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```sh
31
+ bin/rails credentials:decrypt # config/credentials.yml.enc -> config/credentials.yml
32
+ # edit config/credentials.yml
33
+ bin/rails credentials:encrypt # config/credentials.yml -> config/credentials.yml.enc
34
+ ```
35
+
36
+ For per-environment credentials, add the environment:
37
+
38
+ ```sh
39
+ bin/rails credentials:decrypt:production # config/credentials/production.yml.enc -> config/credentials/production.yml
40
+ bin/rails credentials:encrypt:production
41
+ ```
42
+
43
+ ## Development
44
+
45
+ ```sh
46
+ mise install && bin/setup # Ruby from .tool-versions, then bundle install
47
+ bundle exec rspec # tests
48
+ bundle exec rubocop -A # lint with auto-fix
49
+ bundle exec steep check # types: lib/ against sig/
50
+ ```
51
+
52
+ To try it in a Rails application, point its Gemfile at your checkout and run the tasks there:
53
+
54
+ ```rb
55
+ gem "rails_credentials_cipher", path: "../rails_credentials_cipher", group: :development
56
+ ```
57
+
58
+ To release, bump the version in `lib/rails_credentials_cipher/version.rb`, run `bundle install` so `Gemfile.lock` follows, commit, then:
59
+
60
+ ```sh
61
+ bundle exec rake release # tags v<version>, pushes the commits and the tag, pushes the gem to rubygems.org
62
+ ```
63
+
64
+ ## Contributing
65
+
66
+ Bug reports and pull requests are welcome on GitHub at https://github.com/thisismydesign/rails_credentials_cipher.
67
+
68
+ ## License
69
+
70
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module RailsCredentialsCipher
6
+ module Generators
7
+ # bin/rails generate rails_credentials_cipher:install
8
+ class InstallGenerator < Rails::Generators::Base
9
+ desc 'Adds the decrypted credentials files to .gitignore'
10
+
11
+ IGNORES = ['/config/credentials.yml', '/config/credentials/*.yml'].freeze
12
+
13
+ def ignore_decrypted_credentials
14
+ missing = IGNORES.reject { |pattern| ignored?(pattern) }
15
+ return say_status :identical, '.gitignore already ignores the decrypted credentials', :blue if missing.empty?
16
+
17
+ lines = "# Decrypted credentials, see rails_credentials_cipher\n#{missing.join("\n")}\n"
18
+ if File.exist?(gitignore_path)
19
+ append_to_file '.gitignore', "\n#{lines}"
20
+ else
21
+ create_file '.gitignore', lines
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def gitignore_path
28
+ File.join(destination_root, '.gitignore')
29
+ end
30
+
31
+ # A pattern with or without the leading slash covers the same file at the root.
32
+ def ignored?(pattern)
33
+ return false unless File.exist?(gitignore_path)
34
+
35
+ File.readlines(gitignore_path, chomp: true).map { |line| line.strip.delete_prefix('/') }
36
+ .include?(pattern.delete_prefix('/'))
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'active_support'
5
+ require 'active_support/encrypted_file'
6
+
7
+ module RailsCredentialsCipher
8
+ # Moves one encrypted file between its encrypted form (`config/credentials.yml.enc`)
9
+ # and a plain file next to it (`config/credentials.yml`), using the same
10
+ # ActiveSupport::EncryptedFile that Rails reads the credentials with.
11
+ class Cipher
12
+ attr_reader :encrypted_path, :key_path, :env_key, :plain_path
13
+
14
+ def initialize(encrypted_path:, key_path:, env_key: 'RAILS_MASTER_KEY', plain_path: nil)
15
+ @encrypted_path = Pathname(encrypted_path)
16
+ @key_path = Pathname(key_path)
17
+ @env_key = env_key
18
+ @plain_path = plain_path ? Pathname(plain_path) : default_plain_path
19
+ end
20
+
21
+ # Writes the decrypted content to the plain path. Returns the plain path.
22
+ def decrypt
23
+ plain_path.binwrite(encrypted_file.read)
24
+ plain_path
25
+ end
26
+
27
+ # Encrypts the plain file over the encrypted path. Returns the encrypted
28
+ # path when it was rewritten and nil when the content was already the same,
29
+ # so an unchanged file does not get a new ciphertext.
30
+ def encrypt
31
+ raise Error, "#{plain_path} does not exist, decrypt first" unless plain_path.exist?
32
+
33
+ contents = plain_path.binread
34
+ return if unchanged?(contents)
35
+
36
+ encrypted_file.write(contents)
37
+ encrypted_path
38
+ end
39
+
40
+ private
41
+
42
+ def default_plain_path
43
+ unless encrypted_path.extname == '.enc'
44
+ raise Error,
45
+ "cannot derive a plain path from #{encrypted_path}, pass plain_path:"
46
+ end
47
+
48
+ encrypted_path.sub_ext('')
49
+ end
50
+
51
+ def unchanged?(contents)
52
+ encrypted_path.exist? && encrypted_file.read == contents
53
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
54
+ false
55
+ end
56
+
57
+ def encrypted_file
58
+ @encrypted_file ||= ActiveSupport::EncryptedFile.new(
59
+ content_path: encrypted_path,
60
+ key_path: key_path,
61
+ env_key: env_key,
62
+ raise_if_missing_key: true
63
+ )
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsCredentialsCipher
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path('../tasks/credentials.rake', File.dirname(__FILE__))
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsCredentialsCipher
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require_relative 'rails_credentials_cipher/version'
5
+ require_relative 'rails_credentials_cipher/cipher'
6
+ require_relative 'rails_credentials_cipher/railtie' if defined?(Rails::Railtie)
7
+
8
+ module RailsCredentialsCipher
9
+ class Error < StandardError; end
10
+
11
+ class << self
12
+ # Decrypts the application's credentials into a plain file next to the
13
+ # encrypted one, and warns when that file is not ignored by git.
14
+ def decrypt(environment: nil, out: $stdout)
15
+ cipher = cipher(environment:)
16
+ explaining(cipher) { cipher.decrypt }
17
+ out.puts "Decrypted #{relative(cipher.encrypted_path)} to #{relative(cipher.plain_path)}"
18
+ out.puts "Warning: #{relative(cipher.plain_path)} is not ignored by git" if git_tracked?(cipher.plain_path)
19
+ cipher.plain_path
20
+ end
21
+
22
+ # Encrypts the plain file back over the application's credentials.
23
+ def encrypt(environment: nil, out: $stdout)
24
+ cipher = cipher(environment:)
25
+ if explaining(cipher) { cipher.encrypt }
26
+ out.puts "Encrypted #{relative(cipher.plain_path)} to #{relative(cipher.encrypted_path)}"
27
+ else
28
+ out.puts "#{relative(cipher.encrypted_path)} already matches #{relative(cipher.plain_path)}, nothing to do"
29
+ end
30
+ cipher.encrypted_path
31
+ end
32
+
33
+ # The environments the application defines, which is what Rails' own
34
+ # credentials command offers for --environment.
35
+ def environments
36
+ Dir[Rails.root.join('config/environments/*.rb').to_s].map { |file| File.basename(file, '.rb') }.sort
37
+ end
38
+
39
+ # The cipher for the credentials the running application would use, or for
40
+ # the given environment's `config/credentials/<environment>.yml.enc`. The key
41
+ # falls back from `config/credentials/<environment>.key` to `config/master.key`
42
+ # the way Rails does; `RAILS_MASTER_KEY` wins over both.
43
+ def cipher(environment: nil)
44
+ encrypted_path, key_path = paths(environment:)
45
+ Cipher.new(encrypted_path:, key_path:)
46
+ end
47
+
48
+ private
49
+
50
+ # Turns the encryption errors into one Error whose message says what to do.
51
+ def explaining(cipher)
52
+ yield
53
+ rescue ActiveSupport::EncryptedFile::MissingKeyError
54
+ raise Error, "No key for #{relative(cipher.encrypted_path)}: " \
55
+ "set #{cipher.env_key} or write it to #{relative(cipher.key_path)}"
56
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
57
+ raise Error, "Could not decrypt #{relative(cipher.encrypted_path)}: " \
58
+ "the key in #{cipher.env_key} or #{relative(cipher.key_path)} is not the one it was encrypted with"
59
+ rescue ActiveSupport::EncryptedFile::MissingContentError
60
+ raise Error, "#{relative(cipher.encrypted_path)} does not exist"
61
+ end
62
+
63
+ def paths(environment:)
64
+ return app_paths unless environment
65
+
66
+ key_path = Rails.root.join("config/credentials/#{environment}.key")
67
+ key_path = Rails.root.join('config/master.key') unless key_path.exist?
68
+ [Rails.root.join("config/credentials/#{environment}.yml.enc"), key_path]
69
+ end
70
+
71
+ def app_paths
72
+ config = Rails.application.config.credentials
73
+ [Rails.root.join(config.content_path), Rails.root.join(config.key_path)]
74
+ end
75
+
76
+ def relative(path)
77
+ path.relative_path_from(Rails.root)
78
+ end
79
+
80
+ # True only when git says the file is not ignored; a missing git or a
81
+ # directory outside any repository is not a reason to warn.
82
+ def git_tracked?(path)
83
+ system('git', 'check-ignore', '-q', path.to_s, chdir: Rails.root.to_s, out: File::NULL, err: File::NULL)
84
+ $CHILD_STATUS&.exitstatus == 1
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stops with the explanation instead of a stack trace.
4
+ abort_on_error = lambda do |&work|
5
+ work.call
6
+ rescue RailsCredentialsCipher::Error => e
7
+ abort e.message
8
+ end
9
+
10
+ namespace :credentials do
11
+ desc 'Decrypt the credentials into a plain file for editing'
12
+ task decrypt: :environment do
13
+ abort_on_error.call { RailsCredentialsCipher.decrypt }
14
+ end
15
+
16
+ desc 'Encrypt the plain file back over the credentials'
17
+ task encrypt: :environment do
18
+ abort_on_error.call { RailsCredentialsCipher.encrypt }
19
+ end
20
+
21
+ RailsCredentialsCipher.environments.each do |env|
22
+ namespace :decrypt do
23
+ desc "Decrypt config/credentials/#{env}.yml.enc into a plain file for editing"
24
+ task env => :environment do
25
+ abort_on_error.call { RailsCredentialsCipher.decrypt(environment: env) }
26
+ end
27
+ end
28
+
29
+ namespace :encrypt do
30
+ desc "Encrypt the plain file back over config/credentials/#{env}.yml.enc"
31
+ task env => :environment do
32
+ abort_on_error.call { RailsCredentialsCipher.encrypt(environment: env) }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,58 @@
1
+ module RailsCredentialsCipher
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ interface _Puts
8
+ def puts: (*untyped) -> void
9
+ end
10
+
11
+ type paths = [Pathname, Pathname]
12
+
13
+ def self.decrypt: (?environment: String?, ?out: _Puts) -> Pathname
14
+ def self.encrypt: (?environment: String?, ?out: _Puts) -> Pathname
15
+ def self.environments: () -> Array[String]
16
+ def self.cipher: (?environment: String?) -> Cipher
17
+
18
+ def self.explaining: [T] (Cipher cipher) { () -> T } -> T
19
+ def self.paths: (environment: String?) -> paths
20
+ def self.app_paths: () -> paths
21
+ def self.relative: (Pathname path) -> Pathname
22
+ def self.git_tracked?: (Pathname path) -> bool
23
+
24
+ class Cipher
25
+ attr_reader encrypted_path: Pathname
26
+ attr_reader key_path: Pathname
27
+ attr_reader env_key: String
28
+ attr_reader plain_path: Pathname
29
+
30
+ @encrypted_file: ActiveSupport::EncryptedFile?
31
+
32
+ def initialize: (encrypted_path: String | Pathname, key_path: String | Pathname, ?env_key: String, ?plain_path: (String | Pathname)?) -> void
33
+ def decrypt: () -> Pathname
34
+ def encrypt: () -> Pathname?
35
+
36
+ private
37
+
38
+ def default_plain_path: () -> Pathname
39
+ def unchanged?: (String contents) -> bool
40
+ def encrypted_file: () -> ActiveSupport::EncryptedFile
41
+ end
42
+
43
+ class Railtie < Rails::Railtie
44
+ end
45
+
46
+ module Generators
47
+ class InstallGenerator < Rails::Generators::Base
48
+ IGNORES: Array[String]
49
+
50
+ def ignore_decrypted_credentials: () -> void
51
+
52
+ private
53
+
54
+ def gitignore_path: () -> String
55
+ def ignored?: (String pattern) -> bool
56
+ end
57
+ end
58
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_credentials_cipher
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - thisismydesign
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ email:
27
+ - git.thisismydesign@gmail.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - ".rspec"
33
+ - ".rubocop.yml"
34
+ - ".tool-versions"
35
+ - AGENTS.md
36
+ - LICENSE.txt
37
+ - README.md
38
+ - Rakefile
39
+ - lib/generators/rails_credentials_cipher/install/install_generator.rb
40
+ - lib/rails_credentials_cipher.rb
41
+ - lib/rails_credentials_cipher/cipher.rb
42
+ - lib/rails_credentials_cipher/railtie.rb
43
+ - lib/rails_credentials_cipher/version.rb
44
+ - lib/tasks/credentials.rake
45
+ - sig/rails_credentials_cipher.rbs
46
+ homepage: https://github.com/thisismydesign/rails_credentials_cipher
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://github.com/thisismydesign/rails_credentials_cipher
51
+ source_code_uri: https://github.com/thisismydesign/rails_credentials_cipher
52
+ rubygems_mfa_required: 'true'
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 3.3.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 4.0.10
68
+ specification_version: 4
69
+ summary: Decrypt, edit, and encrypt Rails credentials.
70
+ test_files: []