1# frozen_string_literal: true 2 3module Gitlab 4 module Gpg 5 extend self 6 7 CleanupError = Class.new(StandardError) 8 BG_CLEANUP_RUNTIME_S = 10 9 FG_CLEANUP_RUNTIME_S = 1 10 11 MUTEX = Mutex.new 12 13 module CurrentKeyChain 14 extend self 15 16 def add(key) 17 GPGME::Key.import(key) 18 end 19 20 def fingerprints_from_key(key) 21 import = GPGME::Key.import(key) 22 23 return [] if import.imported == 0 24 25 import.imports.map(&:fingerprint) 26 end 27 end 28 29 def fingerprints_from_key(key) 30 using_tmp_keychain do 31 CurrentKeyChain.fingerprints_from_key(key) 32 end 33 end 34 35 def primary_keyids_from_key(key) 36 using_tmp_keychain do 37 fingerprints = CurrentKeyChain.fingerprints_from_key(key) 38 39 GPGME::Key.find(:public, fingerprints).map { |raw_key| raw_key.primary_subkey.keyid } 40 end 41 end 42 43 def subkeys_from_key(key) 44 using_tmp_keychain do 45 fingerprints = CurrentKeyChain.fingerprints_from_key(key) 46 raw_keys = GPGME::Key.find(:public, fingerprints) 47 48 raw_keys.each_with_object({}) do |raw_key, grouped_subkeys| 49 primary_subkey_id = raw_key.primary_subkey.keyid 50 51 grouped_subkeys[primary_subkey_id] = raw_key.subkeys[1..].map do |s| 52 { keyid: s.keyid, fingerprint: s.fingerprint } 53 end 54 end 55 end 56 end 57 58 def user_infos_from_key(key) 59 using_tmp_keychain do 60 fingerprints = CurrentKeyChain.fingerprints_from_key(key) 61 62 GPGME::Key.find(:public, fingerprints).flat_map do |raw_key| 63 raw_key.uids.each_with_object([]) do |uid, arr| 64 name = uid.name.force_encoding('UTF-8') 65 email = uid.email.force_encoding('UTF-8') 66 arr << { name: name, email: email.downcase } if name.valid_encoding? && email.valid_encoding? 67 end 68 end 69 end 70 end 71 72 # Allows thread safe switching of temporary keychain files 73 # 74 # 1. The current thread may use nesting of temporary keychain 75 # 2. Another thread needs to wait for the lock to be released 76 def using_tmp_keychain(&block) 77 if MUTEX.locked? && MUTEX.owned? 78 optimistic_using_tmp_keychain(&block) 79 else 80 ActiveSupport::Dependencies.interlock.permit_concurrent_loads do 81 MUTEX.synchronize do 82 optimistic_using_tmp_keychain(&block) 83 end 84 end 85 end 86 end 87 88 # 1. Returns the custom home directory if one has been set by calling 89 # `GPGME::Engine.home_dir=` 90 # 2. Returns the default home directory otherwise 91 def current_home_dir 92 GPGME::Engine.info.first.home_dir || GPGME::Engine.dirinfo('homedir') 93 end 94 95 private 96 97 def optimistic_using_tmp_keychain 98 previous_dir = current_home_dir 99 tmp_dir = Dir.mktmpdir 100 GPGME::Engine.home_dir = tmp_dir 101 tmp_keychains_created.increment 102 103 yield 104 ensure 105 GPGME::Engine.home_dir = previous_dir 106 107 begin 108 cleanup_tmp_dir(tmp_dir) 109 rescue CleanupError => e 110 folder_contents = Dir.children(tmp_dir) 111 # This means we left a GPG-agent process hanging. Logging the problem in 112 # sentry will make this more visible. 113 Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, 114 issue_url: 'https://gitlab.com/gitlab-org/gitlab/issues/20918', 115 tmp_dir: tmp_dir, contents: folder_contents) 116 end 117 118 tmp_keychains_removed.increment unless File.exist?(tmp_dir) 119 end 120 121 def cleanup_tmp_dir(tmp_dir) 122 # Retry when removing the tmp directory failed, as we may run into a 123 # race condition: 124 # The `gpg-agent` agent process may clean up some files as well while 125 # `FileUtils.remove_entry` is iterating the directory and removing all 126 # its contained files and directories recursively, which could raise an 127 # error. 128 # Failing to remove the tmp directory could leave the `gpg-agent` process 129 # running forever. 130 # 131 # 15 tries will never complete within the maximum time with exponential 132 # backoff. So our limit is the runtime, not the number of tries. 133 Retriable.retriable(max_elapsed_time: cleanup_time, base_interval: 0.1, tries: 15) do 134 FileUtils.remove_entry(tmp_dir) if File.exist?(tmp_dir) 135 end 136 rescue StandardError => e 137 raise CleanupError, e 138 end 139 140 def cleanup_time 141 Gitlab::Runtime.sidekiq? ? BG_CLEANUP_RUNTIME_S : FG_CLEANUP_RUNTIME_S 142 end 143 144 def tmp_keychains_created 145 Gitlab::Metrics.counter(:gpg_tmp_keychains_created_total, 'The number of temporary GPG keychains created') 146 end 147 148 def tmp_keychains_removed 149 Gitlab::Metrics.counter(:gpg_tmp_keychains_removed_total, 'The number of temporary GPG keychains removed') 150 end 151 end 152end 153