ironclad 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e2b08a6c0985159ff7be4790655703218e7eef6428416a68eba9fe8d039d16b
4
- data.tar.gz: 0d14ea6406e375cad4f6045ab1e1bf0bcfa6a85deb9a3c995b745d786ef5f823
3
+ metadata.gz: 3a87bf5f6911710634655b6619b79f20feec36c8c91033e4174b84f6d51eb643
4
+ data.tar.gz: a594caae3ea3825b7fbd5984301ad90298b163174e7ec1378274c117e1c7c05e
5
5
  SHA512:
6
- metadata.gz: 2c5bee8e2a72bb805d56c58884d951008a02408e3d99ddfeef8ec4723af1a619b130c5a36f0e668de0f5ddad720116cb50ed3319f8994970e7f65fb01de501a5
7
- data.tar.gz: 3bc45b204cb6f95ebe763000b13d388c5c737602b273f16380bf2e6dc1f1d4260f896e73b5e855465245ca2bd39e9c9dfc221471109d06bd8b5d2de94af3a0ba
6
+ metadata.gz: 2e554621354d643f0ca215b07194774d2e634ad3a6d0730029ad492f10545de281c755535b74e944f44a0926727deeb9ea392dc9618acc93404bba1af74cc4b5
7
+ data.tar.gz: 79fb39bfd14bcd75f312193f60484cf970ea68fea9a1ce420302955f5062a671a99c676aa2b5c9b33994a0b0818b8b5a78dd37ed1cd136340d84e1007b058933
data/README.md CHANGED
@@ -11,7 +11,8 @@ Ironclad reads each environment's key (`master.key`, `production.key`) from
11
11
  Linux kernel keyring — so repeated use doesn't round-trip to 1Password. It
12
12
  ships:
13
13
 
14
- - a CLI for printing a key or editing credentials,
14
+ - a CLI for printing a key, editing credentials, and keeping `git diff`
15
+ readable,
15
16
  - a Railtie that loads the current environment's key into `ENV` at boot,
16
17
  - Capistrano helpers so deploys need no key file, and
17
18
  - an install generator that wires it all into a Rails app.
@@ -24,8 +25,9 @@ ships:
24
25
  2. On a miss, read it from 1Password (`op read`) and seed the cache.
25
26
 
26
27
  The cache never expires. After rotating a key, refresh it with
27
- `bin/ironclad <env> --refresh`. Platforms with no supported keystore simply
28
- fetch from 1Password every call.
28
+ `bin/ironclad refresh <env>`, or refresh every environment at once with
29
+ `bin/ironclad refresh --all`. Platforms with no supported keystore simply fetch
30
+ from 1Password every call.
29
31
 
30
32
  ## Requirements
31
33
 
@@ -80,10 +82,12 @@ keys:
80
82
  ### CLI
81
83
 
82
84
  ```sh
83
- bin/ironclad # print the development key
84
- bin/ironclad production # print the production key
85
- bin/ironclad production --refresh # bypass the cache after a rotation
86
- bin/ironclad edit staging # edit staging credentials in your editor
85
+ bin/ironclad # print the development key
86
+ bin/ironclad production # print the production key
87
+ bin/ironclad refresh # re-cache the development key after a rotation
88
+ bin/ironclad refresh production # re-cache production's key
89
+ bin/ironclad refresh --all # re-cache every configured environment's key
90
+ bin/ironclad edit staging # edit staging credentials in your editor
87
91
  ```
88
92
 
89
93
  ### Capistrano
@@ -42,6 +42,7 @@ module Ironclad
42
42
 
43
43
  Print a key: bin/ironclad [env]
44
44
  Edit credentials: bin/ironclad edit [env]
45
+ After a rotation: bin/ironclad refresh [env]
45
46
  MESSAGE
46
47
  end
47
48
 
@@ -22,11 +22,13 @@ module Ironclad
22
22
  def write(name, key)
23
23
  # The key passes via argv (briefly visible to the same user's `ps`); the
24
24
  # security tool has no stdin input mode. Acceptable for a local cache.
25
+ # system returns nil when `security` cannot be executed at all, which is
26
+ # a failure to cache just as much as a non-zero exit is.
25
27
  system(
26
28
  'security', 'add-generic-password', '-U',
27
29
  '-a', @account, '-s', name, '-w', key,
28
30
  out: File::NULL, err: File::NULL
29
- )
31
+ ) || false
30
32
  end
31
33
  end
32
34
  end
@@ -15,15 +15,46 @@ module Ironclad
15
15
  end
16
16
 
17
17
  def read(name)
18
- id, status = Open3.capture2('keyctl', 'search', '@u', 'user', name)
18
+ id, _error, status = Open3.capture3(
19
+ 'keyctl', 'search', '@u', 'user', name
20
+ )
19
21
  return unless status.success?
20
22
 
21
- out, status = Open3.capture2('keyctl', 'pipe', id.chomp)
23
+ out, _error, status = Open3.capture3('keyctl', 'pipe', id.chomp)
22
24
  status.success? ? out : nil
23
25
  end
24
26
 
25
27
  def write(name, key)
26
- Open3.capture2('keyctl', 'padd', 'user', name, '@u', stdin_data: key)
28
+ # `keyctl padd` creates the key with the kernel's default permissions
29
+ # and offers no way to set them at creation, so changing them afterwards
30
+ # needs setattr, which only a possessor has. @u grants the owning user
31
+ # link permission, so linking it into @s first guarantees possession.
32
+ Open3.capture3('keyctl', 'link', '@u', '@s')
33
+
34
+ id, error, status = Open3.capture3(
35
+ 'keyctl', 'padd', 'user', name, '@u', stdin_data: key
36
+ )
37
+ return warn_failure(error, "cache #{name}") unless status.success?
38
+
39
+ # possessor 0x3f (all), user 0x2f (view, read, write, search, setattr),
40
+ # group and other none. A caller that does not possess the key gets only
41
+ # the user byte, which has to cover every operation performed here.
42
+ _out, error, status = Open3.capture3(
43
+ 'keyctl', 'setperm', id.chomp, '0x3f2f0000'
44
+ )
45
+ return warn_failure(error, "set permissions on #{name}") unless status.success?
46
+
47
+ true
48
+ end
49
+
50
+ private
51
+
52
+ def warn_failure(error, action)
53
+ detail = error.strip
54
+ message = "ironclad: could not #{action} in the Linux keyring"
55
+ message += ": #{detail}" unless detail.empty?
56
+ warn message
57
+ false
27
58
  end
28
59
  end
29
60
  end
@@ -7,7 +7,7 @@ module Ironclad
7
7
  class Null
8
8
  def read(_name) = nil
9
9
 
10
- def write(_name, _key) = nil
10
+ def write(_name, _key) = true
11
11
  end
12
12
  end
13
13
  end
@@ -6,7 +6,10 @@ require_relative 'cache/keyctl'
6
6
  require_relative 'cache/null'
7
7
 
8
8
  module Ironclad
9
- # Selects the OS keystore backend for the current platform.
9
+ # Selects the OS keystore backend for the current platform. A backend responds
10
+ # to #read(name), returning the cached string or nil, and #write(name, key),
11
+ # returning true when the key is cached and false when it is not. A backend
12
+ # with nowhere to store keys reports true: nothing to do is not a failure.
10
13
  module Cache
11
14
  module_function
12
15
 
data/lib/ironclad/cli.rb CHANGED
@@ -6,8 +6,11 @@ module Ironclad
6
6
  # Command-line entry point. Dependency-free arg parsing keeps boot cheap and
7
7
  # avoids loading Rails just to print a key.
8
8
  #
9
- # ironclad [env] [--refresh] print the credentials key (default env)
9
+ # ironclad [env] print the credentials key (default env)
10
+ # ironclad refresh [env] re-cache one environment's key
11
+ # ironclad refresh --all re-cache every environment's key
10
12
  # ironclad edit [env] edit Rails credentials for env
13
+ # ironclad diff <file> git textconv: decrypt a credentials file
11
14
  class CLI
12
15
  def self.start(argv)
13
16
  new(argv).run
@@ -19,9 +22,15 @@ module Ironclad
19
22
 
20
23
  def run
21
24
  case @argv.first
25
+ when 'refresh'
26
+ @argv.shift
27
+ return refresh
22
28
  when 'edit'
23
29
  @argv.shift
24
30
  edit(@argv.shift || 'default')
31
+ when 'diff'
32
+ @argv.shift
33
+ diff(@argv.shift)
25
34
  when '-h', '--help', 'help'
26
35
  print_help
27
36
  else
@@ -36,21 +45,58 @@ module Ironclad
36
45
  private
37
46
 
38
47
  def print_key
39
- refresh = @argv.delete('--refresh') ? true : false
40
48
  env = @argv.shift || 'default'
41
49
  validate_env!(env)
42
- puts Ironclad.key(env, refresh: refresh)
50
+ puts Ironclad.key(env)
51
+ end
52
+
53
+ def refresh
54
+ return refresh_all if @argv.delete('--all')
55
+
56
+ env = @argv.shift || 'default'
57
+ validate_env!(env)
58
+ refresh_key(env) ? 0 : 1
59
+ end
60
+
61
+ def refresh_all
62
+ environments = Ironclad.config.environments
63
+ if environments.empty?
64
+ raise Error, 'No environments are configured in config/ironclad.yml.'
65
+ end
66
+
67
+ failed = environments.reject { |env| refresh_key(env) }
68
+ return 0 if failed.empty?
69
+
70
+ warn "Failed to refresh: #{failed.join(', ')}."
71
+ 1
72
+ end
73
+
74
+ def refresh_key(env)
75
+ Ironclad.key(env, refresh: true)
76
+ puts "#{env}: refreshed"
77
+ true
78
+ rescue Error => e
79
+ warn "#{env}: #{e.message}"
80
+ false
43
81
  end
44
82
 
45
83
  def edit(env)
46
84
  validate_env!(env)
47
85
  ENV['RAILS_MASTER_KEY'] = Ironclad.key(env)
86
+ Ironclad.configure_git_diff!
48
87
 
49
88
  args = ['credentials:edit']
50
89
  args.push('-e', env) unless env == 'default'
51
90
  exec('bin/rails', *args)
52
91
  end
53
92
 
93
+ def diff(path)
94
+ raise Error, 'Usage: ironclad diff <file>' unless path
95
+
96
+ require_relative 'diff'
97
+ Diff.call(path)
98
+ end
99
+
54
100
  def validate_env!(env)
55
101
  return if Ironclad.config.environments.include?(env)
56
102
 
@@ -63,11 +109,14 @@ module Ironclad
63
109
  ironclad — Rails credential keys from 1Password, cached locally.
64
110
 
65
111
  Usage:
66
- ironclad [env] [--refresh] print the credentials key (env: default)
112
+ ironclad [env] print the credentials key (env: default)
67
113
  ironclad edit [env] edit Rails credentials for env
114
+ ironclad diff <file> git textconv: decrypt a credentials file
115
+ ironclad refresh [env] re-cache one key (env: default)
116
+ ironclad refresh --all re-cache every environment's key
68
117
  ironclad --help show this help
69
118
 
70
- --refresh re-reads from the source after a key rotation.
119
+ refresh re-reads from the source after a key rotation.
71
120
  Environments are defined in config/ironclad.yml.
72
121
  HELP
73
122
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/encrypted_file'
5
+
6
+ module Ironclad
7
+ module Diff
8
+ ENV_KEY = 'IRONCLAD_DIFF_KEY'
9
+
10
+ module_function
11
+
12
+ def call(path, out = $stdout)
13
+ out.write(decrypt(path) || File.read(path))
14
+ end
15
+
16
+ def decrypt(path)
17
+ key = Ironclad.key(environment_for(path))
18
+ return unless key
19
+
20
+ ENV[ENV_KEY] = key
21
+ ActiveSupport::EncryptedFile.new(
22
+ content_path: path, key_path: File::NULL,
23
+ env_key: ENV_KEY, raise_if_missing_key: true
24
+ ).read
25
+ rescue Ironclad::Error, ActiveSupport::MessageEncryptor::InvalidMessage
26
+ nil
27
+ end
28
+
29
+ def environment_for(path)
30
+ env = File.basename(path).delete_suffix('.yml.enc')
31
+ Ironclad.config.key?(env) ? env : 'default'
32
+ end
33
+ end
34
+ end
@@ -22,7 +22,12 @@ module Ironclad
22
22
  end
23
23
 
24
24
  fetched = @source.read(@config.reference(environment))
25
- @cache.write(name, fetched)
25
+ cached = @cache.write(name, fetched)
26
+ if refresh && !cached
27
+ raise Error,
28
+ "Could not cache refreshed credentials key for #{environment}"
29
+ end
30
+
26
31
  fetched
27
32
  end
28
33
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ironclad
4
- VERSION = '0.3.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/ironclad.rb CHANGED
@@ -12,6 +12,9 @@ require_relative 'ironclad/key_store'
12
12
  module Ironclad
13
13
  class Error < StandardError; end
14
14
 
15
+ GIT_DIFF_DRIVER = 'rails_credentials'
16
+ DIFF_COMMAND = 'bin/ironclad diff'
17
+
15
18
  class << self
16
19
  # Path to the project's ironclad.yml. Override before first use if needed.
17
20
  attr_writer :config_path
@@ -38,11 +41,26 @@ module Ironclad
38
41
  store.key(environment.to_s, refresh: refresh)
39
42
  end
40
43
 
44
+ def configure_git_diff!(command = DIFF_COMMAND)
45
+ return unless enrolled_in_git_diff?
46
+
47
+ system('git', 'config', "diff.#{GIT_DIFF_DRIVER}.textconv", command,
48
+ %i[out err] => File::NULL)
49
+ end
50
+
41
51
  # Reset memoized state (mainly for tests).
42
52
  def reset!
43
53
  @config = nil
44
54
  @store = nil
45
55
  end
56
+
57
+ private
58
+
59
+ def enrolled_in_git_diff?
60
+ attributes = File.join(Dir.pwd, '.gitattributes')
61
+ File.file?(attributes) &&
62
+ File.read(attributes).include?("diff=#{GIT_DIFF_DRIVER}")
63
+ end
46
64
  end
47
65
  end
48
66
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ironclad
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jarrett Lusso
@@ -104,6 +104,7 @@ files:
104
104
  - lib/ironclad/capistrano.rb
105
105
  - lib/ironclad/cli.rb
106
106
  - lib/ironclad/config.rb
107
+ - lib/ironclad/diff.rb
107
108
  - lib/ironclad/key_store.rb
108
109
  - lib/ironclad/railtie.rb
109
110
  - lib/ironclad/source.rb