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?
  local_only_from_env? || local_only_from_file?
end

.local_only_from_env?Boolean

Public predicate for the env-var source. Mirrors local_only_from_file? so status’s “Source: …” attribution can distinguish env vs file using the same truthy parser the cascade uses (a literal env value of “0” / “false” reads as off, not as “env var enabled it”).

Returns:

  • (Boolean)


82
83
84
# File 'lib/mysigner/config.rb', line 82

def self.local_only_from_env?
  truthy_env?(ENV_LOCAL_ONLY)
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)


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

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)



107
108
109
110
111
112
113
114
115
116
117
# File 'lib/mysigner/config.rb', line 107

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



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/mysigner/config.rb', line 206

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



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/mysigner/config.rb', line 293

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)



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/mysigner/config.rb', line 252

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



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/mysigner/config.rb', line 275

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)


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

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

#exists?Boolean

Check if config file exists

Returns:

  • (Boolean)


231
232
233
# File 'lib/mysigner/config.rb', line 231

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.



318
319
320
# File 'lib/mysigner/config.rb', line 318

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)


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

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

#loadObject

Load configuration from file



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/mysigner/config.rb', line 158

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)


102
103
104
# File 'lib/mysigner/config.rb', line 102

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

#org_name(org_id = nil) ⇒ Object

Get organization name



135
136
137
138
139
140
141
# File 'lib/mysigner/config.rb', line 135

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



148
149
150
# File 'lib/mysigner/config.rb', line 148

def organization_id
  @current_organization_id
end

#organization_idsObject

Get all organization IDs



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

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

#remove_token_for_org(org_id) ⇒ Object

Remove token for specific organization



153
154
155
# File 'lib/mysigner/config.rb', line 153

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

#saveObject

Save configuration to file



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/mysigner/config.rb', line 187

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



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

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



243
244
245
246
247
248
249
# File 'lib/mysigner/config.rb', line 243

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)


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

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