Class: Mysigner::Config

Inherits:
Object
  • Object
show all
Defined in:
lib/mysigner/config.rb

Constant Summary collapse

CONFIG_DIR =
File.expand_path('~/.mysigner').freeze
CONFIG_FILE =
File.join(CONFIG_DIR, 'config.yml').freeze
KEY_FILE =
File.join(CONFIG_DIR, '.encryption_key').freeze
KEYCHAIN_SERVICE =
'com.mysigner.cli'
KEYCHAIN_ACCOUNT =
'config_encryption_key'
ENV_API_TOKEN =

Environment variable names for CI/CD support

'MYSIGNER_API_TOKEN'
ENV_API_URL =
'MYSIGNER_API_URL'
ENV_EMAIL =
'MYSIGNER_EMAIL'
ENV_ORG_ID =
'MYSIGNER_ORG_ID'
ENV_LOCAL_ONLY =
'MYSIGNER_LOCAL_ONLY'

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeConfig

Returns a new instance of Config.



31
32
33
34
35
36
37
38
39
40
# File 'lib/mysigner/config.rb', line 31

def initialize
  @api_url = nil
  @user_email = nil
  @current_organization_id = nil
  @organizations = {}
  @encryption_enabled = true # Enable by default for security
  @local_only = false
  @from_env = false
  load if exists?
end

Instance Attribute Details

#api_urlObject

Returns the value of attribute api_url.



28
29
30
# File 'lib/mysigner/config.rb', line 28

def api_url
  @api_url
end

#current_organization_idObject

Returns the value of attribute current_organization_id.



28
29
30
# File 'lib/mysigner/config.rb', line 28

def current_organization_id
  @current_organization_id
end

#encryption_enabledObject

Returns the value of attribute encryption_enabled.



28
29
30
# File 'lib/mysigner/config.rb', line 28

def encryption_enabled
  @encryption_enabled
end

#local_onlyObject

Returns the value of attribute local_only.



28
29
30
# File 'lib/mysigner/config.rb', line 28

def local_only
  @local_only
end

#organizationsObject (readonly)

Returns the value of attribute organizations.



29
30
31
# File 'lib/mysigner/config.rb', line 29

def organizations
  @organizations
end

#user_emailObject

Returns the value of attribute user_email.



28
29
30
# File 'lib/mysigner/config.rb', line 28

def user_email
  @user_email
end

Class Method Details

.env_configured?Boolean

Check if all required env vars are set for CI/CD mode

Returns:

  • (Boolean)


43
44
45
46
# File 'lib/mysigner/config.rb', line 43

def self.env_configured?
  ENV.fetch(ENV_API_TOKEN, nil) && !ENV[ENV_API_TOKEN].empty? &&
    ENV.fetch(ENV_ORG_ID, nil) && !ENV[ENV_ORG_ID].empty?
end

.from_envObject

Create a Config from environment variables (for CI/CD)



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/mysigner/config.rb', line 49

def self.from_env
  config = allocate
  config.instance_variable_set(:@encryption_enabled, false)
  config.instance_variable_set(:@from_env, true)
  config.instance_variable_set(:@local_only, false)

  org_id = ENV.fetch(ENV_ORG_ID, nil)
  token = ENV.fetch(ENV_API_TOKEN, nil)
  config.instance_variable_set(:@api_url, ENV[ENV_API_URL] || 'https://mysigner.dev')
  config.instance_variable_set(:@user_email, ENV.fetch(ENV_EMAIL, nil))
  config.instance_variable_set(:@current_organization_id, org_id.to_i)
  config.instance_variable_set(:@organizations, {
                                 org_id.to_s => { 'name' => 'CI', 'token' => token }
                               })

  config
end

.local_only?Boolean

Local-only mode at the Config level: cascade ENV → file. The CLI Helpers concern layers –local-only / –no-local-only on top.

Returns:

  • (Boolean)


74
75
76
# File 'lib/mysigner/config.rb', line 74

def self.local_only?
  truthy_env?(ENV_LOCAL_ONLY) || local_only_from_file?
end

.local_only_from_file?Boolean

Lightweight check that reads only ~/.mysigner/config.yml’s ‘local_only:` key without invoking #load (which decrypts tokens via the Keychain). A user with no MySigner account still needs to flip this setting, so we never raise on a missing/corrupt or absent file — we just return false.

Returns:

  • (Boolean)


83
84
85
86
87
88
# File 'lib/mysigner/config.rb', line 83

def self.local_only_from_file?
  data = YAML.safe_load_file(CONFIG_FILE)
  data.is_a?(Hash) && data['local_only'] == true
rescue Errno::ENOENT, Psych::SyntaxError
  false
end

Instance Method Details

#api_token(org_id = nil) ⇒ Object

Get API token for current organization (or specific org)



99
100
101
102
103
104
105
106
107
108
109
# File 'lib/mysigner/config.rb', line 99

def api_token(org_id = nil)
  org_id ||= @current_organization_id
  return nil if org_id.nil?

  org_data = @organizations[org_id.to_s]
  token = org_data&.dig('token')
  return nil if token.nil?

  # Decrypt if encrypted
  encrypted?(token) ? decrypt_token(token) : token
end

#clearObject

Clear all configuration



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/mysigner/config.rb', line 198

def clear
  @api_url = nil
  @user_email = nil
  @current_organization_id = nil
  @organizations = {}
  @local_only = false

  File.delete(CONFIG_FILE) if exists?

  # On non-macOS the encryption key lives in a file fallback. Wipe it on
  # logout so a fresh login can mint a new key — otherwise the old key
  # would silently encrypt a new token that nobody else can decrypt.
  FileUtils.rm_f(KEY_FILE)

  # Phase 0: logout also purges the keystore cache so a shared machine
  # doesn't leave prior-user keystore blobs on disk.
  keystores_dir = File.expand_path('~/.mysigner/keystores')
  FileUtils.rm_rf(keystores_dir)

  true
rescue StandardError => e
  raise ConfigError, "Failed to clear config: #{e.message}"
end

#disable_encryption!Object

Disable encryption and decrypt all tokens



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/mysigner/config.rb', line 285

def disable_encryption!
  return true unless @encryption_enabled

  # Decrypt all tokens first
  @organizations.each_value do |org_data|
    token = org_data['token']
    next if token.nil? || !encrypted?(token)

    org_data['token'] = decrypt_token(token)
  end

  @encryption_enabled = false
  save
  true
end

#displayObject

Display config (with masked tokens)



244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/mysigner/config.rb', line 244

def display
  current_org_name = org_name(@current_organization_id) || '(not set)'
  current_token = api_token(@current_organization_id)

  display_data = {
    api_url: @api_url || '(not set)',
    user_email: @user_email || '(not set)',
    current_organization: "#{current_org_name} (ID: #{@current_organization_id || 'not set'})",
    current_token: current_token ? mask_token(current_token) : '(not set)'
  }

  # Show all organizations
  if @organizations.any?
    display_data[:all_organizations] = @organizations.map do |org_id, org_data|
      token_status = org_data['token'] ? '' : ''
      "#{org_data['name']} (ID: #{org_id}) #{token_status}"
    end.join(', ')
  end

  display_data
end

#enable_encryption!Object

Enable encryption and re-encrypt all tokens



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/mysigner/config.rb', line 267

def enable_encryption!
  return true if @encryption_enabled

  @encryption_enabled = true

  # Re-encrypt all existing tokens
  @organizations.each_value do |org_data|
    token = org_data['token']
    next if token.nil? || encrypted?(token)

    org_data['token'] = encrypt_token(token)
  end

  save
  true
end

#encrypted_config?Boolean

Check if config uses encryption

Returns:

  • (Boolean)


302
303
304
# File 'lib/mysigner/config.rb', line 302

def encrypted_config?
  @organizations.values.any? { |org_data| encrypted?(org_data['token']) }
end

#exists?Boolean

Check if config file exists

Returns:

  • (Boolean)


223
224
225
# File 'lib/mysigner/config.rb', line 223

def exists?
  File.exist?(CONFIG_FILE)
end

#fetch_encryption_keyObject

Public accessor for the per-machine 32-byte AES-256-GCM key. Exposed so sibling stores (e.g. LocalCredentials) can encrypt secrets under the same key without duplicating the keychain/file fallback. The key itself is created on first read and is stable across calls.



310
311
312
# File 'lib/mysigner/config.rb', line 310

def fetch_encryption_key
  get_or_create_encryption_key
end

#from_env?Boolean

Whether this config was loaded from environment variables

Returns:

  • (Boolean)


68
69
70
# File 'lib/mysigner/config.rb', line 68

def from_env?
  @from_env
end

#has_token_for_org?(org_id) ⇒ Boolean

Check if we have a token for a specific organization

Returns:

  • (Boolean)


121
122
123
124
# File 'lib/mysigner/config.rb', line 121

def has_token_for_org?(org_id)
  token = api_token(org_id)
  !token.nil? && !token.empty?
end

#loadObject

Load configuration from file



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/mysigner/config.rb', line 150

def load
  return false unless exists?

  # mysigner-51 — safe_load_file rejects arbitrary Ruby object
  # instantiation in the YAML (`!ruby/object:Foo` etc.). The config
  # shape is just String/Integer/Boolean/Hash (api_url, user_email,
  # current_organization_id, local_only, organizations: {id => {name,
  # token}}), all in safe_load's default allowed set, so no
  # permitted_classes
  # extension is needed. Low risk (the file is 0600 and user-owned)
  # but cheap hardening against a future RCE if config write or read
  # ever moves outside the owner-only assumption.
  data = YAML.safe_load_file(CONFIG_FILE)

  @api_url = data['api_url']
  @user_email = data['user_email']
  @current_organization_id = data['current_organization_id']
  @organizations = data['organizations'] || {}
  @local_only = data['local_only'] == true

  # Auto-detect encryption from config
  @encryption_enabled = encrypted_config?

  true
rescue StandardError => e
  raise ConfigError, "Failed to load config: #{e.message}"
end

#local_only?Boolean

Instance-level predicate. Merges two surfaces:

- @local_only: set to true by Helpers#blank_local_only_config so a
  sentinel config always answers true without touching ENV or disk.
- self.class.local_only?: the normal ENV → file cascade.

Returns:

  • (Boolean)


94
95
96
# File 'lib/mysigner/config.rb', line 94

def local_only?
  @local_only || self.class.local_only?
end

#org_name(org_id = nil) ⇒ Object

Get organization name



127
128
129
130
131
132
133
# File 'lib/mysigner/config.rb', line 127

def org_name(org_id = nil)
  org_id ||= @current_organization_id
  return nil if org_id.nil?

  org_data = @organizations[org_id.to_s]
  org_data&.dig('name')
end

#organization_idObject



140
141
142
# File 'lib/mysigner/config.rb', line 140

def organization_id
  @current_organization_id
end

#organization_idsObject

Get all organization IDs



136
137
138
# File 'lib/mysigner/config.rb', line 136

def organization_ids
  @organizations.keys.map(&:to_i)
end

#remove_token_for_org(org_id) ⇒ Object

Remove token for specific organization



145
146
147
# File 'lib/mysigner/config.rb', line 145

def remove_token_for_org(org_id)
  @organizations.delete(org_id.to_s)
end

#saveObject

Save configuration to file



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/mysigner/config.rb', line 179

def save
  ensure_config_dir_exists

  data = {
    'api_url' => @api_url,
    'user_email' => @user_email,
    'current_organization_id' => @current_organization_id,
    'organizations' => @organizations,
    'local_only' => @local_only
  }

  File.write(CONFIG_FILE, data.to_yaml)
  File.chmod(0o600, CONFIG_FILE) # Make file readable only by owner
  true
rescue StandardError => e
  raise ConfigError, "Failed to save config: #{e.message}"
end

#save_token_for_org(org_id, org_name, token) ⇒ Object

Save token for a specific organization



112
113
114
115
116
117
118
# File 'lib/mysigner/config.rb', line 112

def save_token_for_org(org_id, org_name, token)
  encrypted_token = @encryption_enabled ? encrypt_token(token) : token
  @organizations[org_id.to_s] = {
    'name' => org_name,
    'token' => encrypted_token
  }
end

#to_hObject

Get config as hash



235
236
237
238
239
240
241
# File 'lib/mysigner/config.rb', line 235

def to_h
  {
    api_url: @api_url,
    user_email: @user_email,
    current_organization_id: @current_organization_id
  }
end

#valid?Boolean

Check if configuration is complete (has required fields)

Returns:

  • (Boolean)


228
229
230
231
232
# File 'lib/mysigner/config.rb', line 228

def valid?
  !@api_url.nil? && !@api_url.empty? &&
    !@current_organization_id.nil? &&
    has_token_for_org?(@current_organization_id)
end