summaryrefslogtreecommitdiff
path: root/lib/gitlab/gpg.rb
blob: 3d9b06855ffcc1df388dfe5e62d7cada83b29283 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# frozen_string_literal: true

module Gitlab
  module Gpg
    extend self

    CleanupError = Class.new(StandardError)
    BG_CLEANUP_RUNTIME_S = 10
    FG_CLEANUP_RUNTIME_S = 1

    MUTEX = Mutex.new

    module CurrentKeyChain
      extend self

      def add(key)
        GPGME::Key.import(key)
      end

      def fingerprints_from_key(key)
        import = GPGME::Key.import(key)

        return [] if import.imported == 0

        import.imports.map(&:fingerprint)
      end
    end

    def fingerprints_from_key(key)
      using_tmp_keychain do
        CurrentKeyChain.fingerprints_from_key(key)
      end
    end

    def primary_keyids_from_key(key)
      using_tmp_keychain do
        fingerprints = CurrentKeyChain.fingerprints_from_key(key)

        GPGME::Key.find(:public, fingerprints).map { |raw_key| raw_key.primary_subkey.keyid }
      end
    end

    def subkeys_from_key(key)
      using_tmp_keychain do
        fingerprints = CurrentKeyChain.fingerprints_from_key(key)
        raw_keys     = GPGME::Key.find(:public, fingerprints)

        raw_keys.each_with_object({}) do |raw_key, grouped_subkeys|
          primary_subkey_id = raw_key.primary_subkey.keyid

          grouped_subkeys[primary_subkey_id] = raw_key.subkeys[1..-1].map do |s|
            { keyid: s.keyid, fingerprint: s.fingerprint }
          end
        end
      end
    end

    def user_infos_from_key(key)
      using_tmp_keychain do
        fingerprints = CurrentKeyChain.fingerprints_from_key(key)

        GPGME::Key.find(:public, fingerprints).flat_map do |raw_key|
          raw_key.uids.each_with_object([]) do |uid, arr|
            name = uid.name.force_encoding('UTF-8')
            email = uid.email.force_encoding('UTF-8')
            arr << { name: name, email: email.downcase } if name.valid_encoding? && email.valid_encoding?
          end
        end
      end
    end

    # Allows thread safe switching of temporary keychain files
    #
    # 1. The current thread may use nesting of temporary keychain
    # 2. Another thread needs to wait for the lock to be released
    def using_tmp_keychain(&block)
      if MUTEX.locked? && MUTEX.owned?
        optimistic_using_tmp_keychain(&block)
      else
        ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
          MUTEX.synchronize do
            optimistic_using_tmp_keychain(&block)
          end
        end
      end
    end

    # 1. Returns the custom home directory if one has been set by calling
    #    `GPGME::Engine.home_dir=`
    # 2. Returns the default home directory otherwise
    def current_home_dir
      GPGME::Engine.info.first.home_dir || GPGME::Engine.dirinfo('homedir')
    end

    private

    def optimistic_using_tmp_keychain
      previous_dir = current_home_dir
      tmp_dir = Dir.mktmpdir
      GPGME::Engine.home_dir = tmp_dir
      tmp_keychains_created.increment

      yield
    ensure
      GPGME::Engine.home_dir = previous_dir

      begin
        cleanup_tmp_dir(tmp_dir)
      rescue CleanupError => e
        folder_contents = Dir.children(tmp_dir)
        # This means we left a GPG-agent process hanging. Logging the problem in
        # sentry will make this more visible.
        Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e,
                                       issue_url: 'https://gitlab.com/gitlab-org/gitlab/issues/20918',
                                       tmp_dir: tmp_dir, contents: folder_contents)
      end

      tmp_keychains_removed.increment unless File.exist?(tmp_dir)
    end

    def cleanup_tmp_dir(tmp_dir)
      # Retry when removing the tmp directory failed, as we may run into a
      # race condition:
      # The `gpg-agent` agent process may clean up some files as well while
      # `FileUtils.remove_entry` is iterating the directory and removing all
      # its contained files and directories recursively, which could raise an
      # error.
      # Failing to remove the tmp directory could leave the `gpg-agent` process
      # running forever.
      #
      # 15 tries will never complete within the maximum time with exponential
      # backoff. So our limit is the runtime, not the number of tries.
      Retriable.retriable(max_elapsed_time: cleanup_time, base_interval: 0.1, tries: 15) do
        FileUtils.remove_entry(tmp_dir) if File.exist?(tmp_dir)
      end
    rescue StandardError => e
      raise CleanupError, e
    end

    def cleanup_time
      Gitlab::Runtime.sidekiq? ? BG_CLEANUP_RUNTIME_S : FG_CLEANUP_RUNTIME_S
    end

    def tmp_keychains_created
      Gitlab::Metrics.counter(:gpg_tmp_keychains_created_total, 'The number of temporary GPG keychains created')
    end

    def tmp_keychains_removed
      Gitlab::Metrics.counter(:gpg_tmp_keychains_removed_total, 'The number of temporary GPG keychains removed')
    end
  end
end