mysigner 0.1.2 → 0.1.4

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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.githooks/pre-commit +15 -0
  3. data/.githooks/pre-push +21 -0
  4. data/.github/workflows/ci.yml +29 -0
  5. data/.gitignore +4 -0
  6. data/.rubocop.yml +55 -0
  7. data/.rubocop_todo.yml +126 -0
  8. data/CHANGELOG.md +96 -0
  9. data/Gemfile +5 -3
  10. data/Gemfile.lock +38 -8
  11. data/README.md +14 -16
  12. data/Rakefile +5 -3
  13. data/bin/console +4 -3
  14. data/bin/setup +3 -0
  15. data/certificate_.cer +0 -0
  16. data/exe/mysigner +19 -2
  17. data/iOS_App_Store_Profile.mobileprovision +1 -0
  18. data/iOS_Distribution_Certificate.cer +1 -0
  19. data/lib/mysigner/build/android_executor.rb +83 -63
  20. data/lib/mysigner/build/android_parser.rb +33 -40
  21. data/lib/mysigner/build/configurator.rb +17 -16
  22. data/lib/mysigner/build/detector.rb +39 -50
  23. data/lib/mysigner/build/error_analyzer.rb +70 -68
  24. data/lib/mysigner/build/executor.rb +30 -37
  25. data/lib/mysigner/build/parser.rb +18 -18
  26. data/lib/mysigner/cleanup/private_keys_purger.rb +41 -0
  27. data/lib/mysigner/cli/auth_commands.rb +771 -764
  28. data/lib/mysigner/cli/build_commands.rb +962 -796
  29. data/lib/mysigner/cli/concerns/actionable_suggestions.rb +208 -154
  30. data/lib/mysigner/cli/concerns/api_helpers.rb +46 -54
  31. data/lib/mysigner/cli/concerns/error_handlers.rb +247 -237
  32. data/lib/mysigner/cli/concerns/helpers.rb +44 -1
  33. data/lib/mysigner/cli/diagnostic_commands.rb +667 -636
  34. data/lib/mysigner/cli/resource_commands.rb +1153 -985
  35. data/lib/mysigner/cli/validate_commands.rb +25 -25
  36. data/lib/mysigner/cli.rb +11 -1
  37. data/lib/mysigner/client.rb +27 -19
  38. data/lib/mysigner/config.rb +161 -60
  39. data/lib/mysigner/export/exporter.rb +38 -37
  40. data/lib/mysigner/signing/certificate_checker.rb +18 -23
  41. data/lib/mysigner/signing/gradle_signing_injector.rb +67 -0
  42. data/lib/mysigner/signing/keystore_manager.rb +81 -61
  43. data/lib/mysigner/signing/validator.rb +38 -40
  44. data/lib/mysigner/signing/wizard.rb +329 -342
  45. data/lib/mysigner/upload/app_store_automation.rb +96 -49
  46. data/lib/mysigner/upload/app_store_submission.rb +87 -92
  47. data/lib/mysigner/upload/asc_rest_uploader.rb +119 -0
  48. data/lib/mysigner/upload/play_store_uploader.rb +164 -144
  49. data/lib/mysigner/upload/uploader.rb +136 -115
  50. data/lib/mysigner/version.rb +3 -1
  51. data/lib/mysigner.rb +13 -11
  52. data/mysigner.gemspec +36 -33
  53. data/profile_.mobileprovision +0 -0
  54. data/test_manual.rb +37 -36
  55. metadata +44 -17
  56. data/.DS_Store +0 -0
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mysigner
2
4
  class CLI < Thor
3
5
  module ValidateCommands
4
6
  def self.included(base)
5
7
  base.class_eval do
6
- desc "validate", "Validate signing configuration on the server"
8
+ desc 'validate', 'Validate signing configuration on the server'
7
9
  long_desc <<~DESC
8
10
  Check if your bundle ID, certificate, and provisioning profile exist and
9
11
  are valid on the My Signer server.
@@ -42,15 +44,15 @@ module Mysigner
42
44
  signing_type = options[:type]
43
45
 
44
46
  unless bundle_id
45
- error "Bundle ID is required. Use --bundle-id or run from an Xcode project directory."
46
- say ""
47
- say "Example: mysigner validate --bundle-id com.example.app --type development", :yellow
47
+ error 'Bundle ID is required. Use --bundle-id or run from an Xcode project directory.'
48
+ say ''
49
+ say 'Example: mysigner validate --bundle-id com.example.app --type development', :yellow
48
50
  exit 1
49
51
  end
50
52
 
51
53
  unless signing_type
52
- error "Signing type is required. Use --type with one of: development, appstore, adhoc, inhouse"
53
- say ""
54
+ error 'Signing type is required. Use --type with one of: development, appstore, adhoc, inhouse'
55
+ say ''
54
56
  say "Example: mysigner validate --bundle-id #{bundle_id} --type development", :yellow
55
57
  exit 1
56
58
  end
@@ -62,11 +64,11 @@ module Mysigner
62
64
  exit 1
63
65
  end
64
66
 
65
- say "🔍 Validating signing configuration...", :cyan
66
- say ""
67
+ say '🔍 Validating signing configuration...', :cyan
68
+ say ''
67
69
  say " Bundle ID: #{bundle_id}", :white
68
70
  say " Type: #{signing_type}", :white
69
- say ""
71
+ say ''
70
72
 
71
73
  begin
72
74
  response = client.post(
@@ -93,17 +95,17 @@ module Mysigner
93
95
  end
94
96
  end
95
97
 
96
- say ""
98
+ say ''
97
99
 
98
100
  if valid
99
- say "✓ All checks passed! Signing configuration is valid.", :green
101
+ say '✓ All checks passed! Signing configuration is valid.', :green
100
102
  else
101
- say "✗ Validation failed. Some checks did not pass.", :red
103
+ say '✗ Validation failed. Some checks did not pass.', :red
102
104
 
103
105
  suggestions = result['suggestions'] || []
104
106
  if suggestions.any?
105
- say ""
106
- say "💡 Suggestions:", :cyan
107
+ say ''
108
+ say '💡 Suggestions:', :cyan
107
109
  suggestions.each do |suggestion|
108
110
  say " → #{suggestion}", :yellow
109
111
  end
@@ -113,26 +115,24 @@ module Mysigner
113
115
  end
114
116
  rescue Mysigner::NotFoundError => e
115
117
  error "Not found: #{e.message}"
116
- say ""
117
- say "💡 Make sure your bundle ID is synced:", :cyan
118
+ say ''
119
+ say '💡 Make sure your bundle ID is synced:', :cyan
118
120
  say " → Run 'mysigner sync ios' to sync from Apple Developer Portal", :yellow
119
121
  say " → Run 'mysigner bundleid list' to list registered bundle IDs", :yellow
120
122
  exit 1
121
123
  rescue Mysigner::ValidationError => e
122
124
  error "Validation error: #{e.message}"
123
- if e.details
124
- e.details.each do |field, errors|
125
- errors_text = errors.is_a?(Array) ? errors.join(', ') : errors.to_s
126
- say " #{field}: #{errors_text}", :red
127
- end
125
+ e.details&.each do |field, errors|
126
+ errors_text = errors.is_a?(Array) ? errors.join(', ') : errors.to_s
127
+ say " #{field}: #{errors_text}", :red
128
128
  end
129
129
  exit 1
130
130
  rescue Mysigner::ClientError => e
131
131
  error "Validation request failed: #{e.message}"
132
- say ""
133
- say "💡 Try these steps:", :cyan
134
- say " → Check your network connection", :yellow
135
- say " → Verify API token: mysigner status", :yellow
132
+ say ''
133
+ say '💡 Try these steps:', :cyan
134
+ say ' → Check your network connection', :yellow
135
+ say ' → Verify API token: mysigner status', :yellow
136
136
  exit 1
137
137
  end
138
138
  end
data/lib/mysigner/cli.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'thor'
2
4
  require 'json'
3
5
  require 'time'
@@ -12,6 +14,14 @@ require_relative 'cli/diagnostic_commands'
12
14
  require_relative 'cli/build_commands'
13
15
  require_relative 'cli/resource_commands'
14
16
  require_relative 'cli/validate_commands'
17
+ require_relative 'cleanup/private_keys_purger'
18
+
19
+ # Phase 0: one-time cleanup of legacy plaintext .p8 files that older CLI
20
+ # versions wrote to ~/.private_keys/ and ~/.appstoreconnect/private_keys/.
21
+ # Idempotent — a marker file at ~/.mysigner/.private_keys_purged prevents
22
+ # re-running. Skipped when MYSIGNER_USE_LEGACY_ASC=1 so users who opted
23
+ # back into the legacy altool path keep their existing keys.
24
+ Mysigner::Cleanup::PrivateKeysPurger.new.call
15
25
 
16
26
  module Mysigner
17
27
  class CLI < Thor
@@ -42,4 +52,4 @@ module Mysigner
42
52
  map 'st' => :status
43
53
  map 'd' => :doctor
44
54
  end
45
- end
55
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'faraday'
2
4
  require 'faraday/retry'
3
5
  require 'json'
@@ -57,7 +59,7 @@ module Mysigner
57
59
  get('/api/v1/status')
58
60
  rescue ClientError => e
59
61
  raise e
60
- rescue => e
62
+ rescue StandardError => e
61
63
  raise ConnectionError, "Failed to connect: #{e.message}"
62
64
  end
63
65
 
@@ -69,9 +71,7 @@ module Mysigner
69
71
  f.request :json
70
72
 
71
73
  # Add X-User-Email header if email is present
72
- if @user_email
73
- f.headers['X-User-Email'] = @user_email
74
- end
74
+ f.headers['X-User-Email'] = @user_email if @user_email
75
75
 
76
76
  # Retry failed requests
77
77
  f.request :retry, {
@@ -80,7 +80,7 @@ module Mysigner
80
80
  interval_randomness: 0.5,
81
81
  backoff_factor: 2,
82
82
  retry_statuses: [429, 502, 503, 504],
83
- methods: [:get, :post, :patch, :delete]
83
+ methods: %i[get post patch delete]
84
84
  }
85
85
 
86
86
  # Response middleware
@@ -120,21 +120,28 @@ module Mysigner
120
120
 
121
121
  case response.status
122
122
  when 401
123
- raise UnauthorizedError.new("Unauthorized: #{error_message}", error_code: error_code, suggestion: suggestion, details: error_data['details'], timestamp: timestamp)
123
+ raise UnauthorizedError.new("Unauthorized: #{error_message}", error_code: error_code, suggestion: suggestion,
124
+ details: error_data['details'], timestamp: timestamp)
124
125
  when 403
125
- raise ForbiddenError.new("Forbidden: #{error_message}", error_code: error_code, suggestion: suggestion, details: error_data['details'], timestamp: timestamp)
126
+ raise ForbiddenError.new("Forbidden: #{error_message}", error_code: error_code, suggestion: suggestion,
127
+ details: error_data['details'], timestamp: timestamp)
126
128
  when 404
127
- raise NotFoundError.new("Not found: #{error_message}", error_code: error_code, suggestion: suggestion, details: error_data['details'], timestamp: timestamp)
129
+ raise NotFoundError.new("Not found: #{error_message}", error_code: error_code, suggestion: suggestion,
130
+ details: error_data['details'], timestamp: timestamp)
128
131
  when 409
129
- raise ValidationError.new(error_message, error_data['details'], suggestion: suggestion, error_code: error_code, timestamp: timestamp)
132
+ raise ValidationError.new(error_message, error_data['details'], suggestion: suggestion, error_code: error_code,
133
+ timestamp: timestamp)
130
134
  when 422
131
- raise ValidationError.new(error_message, error_data['details'], suggestion: suggestion, error_code: error_code, timestamp: timestamp)
135
+ raise ValidationError.new(error_message, error_data['details'], suggestion: suggestion, error_code: error_code,
136
+ timestamp: timestamp)
132
137
  when 429
133
138
  raise RateLimitError.new(error_message, error_data['retry_after'])
134
139
  when 500..599
135
- raise ServerError.new("Server error (#{response.status}): #{error_message}", error_code: error_code, timestamp: timestamp)
140
+ raise ServerError.new("Server error (#{response.status}): #{error_message}", error_code: error_code,
141
+ timestamp: timestamp)
136
142
  else
137
- raise ClientError.new("Request failed (#{response.status}): #{error_message}", error_code: error_code, timestamp: timestamp)
143
+ raise ClientError.new("Request failed (#{response.status}): #{error_message}", error_code: error_code,
144
+ timestamp: timestamp)
138
145
  end
139
146
  end
140
147
 
@@ -146,15 +153,16 @@ module Mysigner
146
153
  # Check if it's a wrapped timeout error
147
154
  if error.wrapped_exception.is_a?(Net::OpenTimeout) || error.wrapped_exception.is_a?(Net::ReadTimeout)
148
155
  raise TimeoutError, "Request timeout: #{error.message}"
149
- else
150
- raise ConnectionError, "Connection failed: #{error.message}"
151
156
  end
157
+
158
+ raise ConnectionError, "Connection failed: #{error.message}"
159
+
152
160
  when Faraday::UnauthorizedError
153
- raise UnauthorizedError, "Invalid or missing API token"
161
+ raise UnauthorizedError, 'Invalid or missing API token'
154
162
  when Faraday::ForbiddenError
155
- raise ForbiddenError, "Access forbidden"
163
+ raise ForbiddenError, 'Access forbidden'
156
164
  when Faraday::ResourceNotFound
157
- raise NotFoundError, "Resource not found"
165
+ raise NotFoundError, 'Resource not found'
158
166
  when Faraday::ClientError
159
167
  raise ClientError, "Client error: #{error.message}"
160
168
  when Faraday::ServerError
@@ -177,13 +185,14 @@ module Mysigner
177
185
  @timestamp = timestamp
178
186
  end
179
187
  end
188
+
180
189
  class ConnectionError < ClientError; end
181
190
  class TimeoutError < ClientError; end
182
191
  class UnauthorizedError < ClientError; end
183
192
  class ForbiddenError < ClientError; end
184
193
  class NotFoundError < ClientError; end
185
194
  class ServerError < ClientError; end
186
-
195
+
187
196
  class ValidationError < ClientError
188
197
  def initialize(message, details = nil, suggestion: nil, error_code: nil, timestamp: nil)
189
198
  super(message, error_code: error_code, suggestion: suggestion, details: details, timestamp: timestamp)
@@ -199,4 +208,3 @@ module Mysigner
199
208
  end
200
209
  end
201
210
  end
202
-
@@ -1,39 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+
1
5
  require 'yaml'
2
6
  require 'fileutils'
3
7
  require 'openssl'
4
8
  require 'base64'
5
9
  require 'json'
6
10
  require 'securerandom'
11
+ require 'rbconfig'
7
12
 
8
13
  module Mysigner
9
14
  class Config
10
- CONFIG_DIR = File.expand_path("~/.mysigner").freeze
11
- CONFIG_FILE = File.join(CONFIG_DIR, "config.yml").freeze
12
- KEYCHAIN_SERVICE = "com.mysigner.cli".freeze
13
- KEYCHAIN_ACCOUNT = "config_encryption_key".freeze
14
-
15
- attr_accessor :api_url, :user_email, :current_organization_id
15
+ CONFIG_DIR = File.expand_path('~/.mysigner').freeze
16
+ CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml').freeze
17
+ KEY_FILE = File.join(CONFIG_DIR, '.encryption_key').freeze
18
+ KEYCHAIN_SERVICE = 'com.mysigner.cli'
19
+ KEYCHAIN_ACCOUNT = 'config_encryption_key'
20
+
21
+ # Environment variable names for CI/CD support
22
+ ENV_API_TOKEN = 'MYSIGNER_API_TOKEN'
23
+ ENV_API_URL = 'MYSIGNER_API_URL'
24
+ ENV_EMAIL = 'MYSIGNER_EMAIL'
25
+ ENV_ORG_ID = 'MYSIGNER_ORG_ID'
26
+
27
+ attr_accessor :api_url, :user_email, :current_organization_id, :encryption_enabled
16
28
  attr_reader :organizations
17
- attr_accessor :encryption_enabled
18
29
 
19
30
  def initialize
20
31
  @api_url = nil
21
32
  @user_email = nil
22
33
  @current_organization_id = nil
23
34
  @organizations = {}
24
- @encryption_enabled = true # Enable by default for security
35
+ @encryption_enabled = true # Enable by default for security
36
+ @from_env = false
25
37
  load if exists?
26
38
  end
27
39
 
40
+ # Check if all required env vars are set for CI/CD mode
41
+ def self.env_configured?
42
+ ENV.fetch(ENV_API_TOKEN, nil) && !ENV[ENV_API_TOKEN].empty? &&
43
+ ENV.fetch(ENV_ORG_ID, nil) && !ENV[ENV_ORG_ID].empty?
44
+ end
45
+
46
+ # Create a Config from environment variables (for CI/CD)
47
+ def self.from_env
48
+ config = allocate
49
+ config.instance_variable_set(:@encryption_enabled, false)
50
+ config.instance_variable_set(:@from_env, true)
51
+
52
+ org_id = ENV.fetch(ENV_ORG_ID, nil)
53
+ token = ENV.fetch(ENV_API_TOKEN, nil)
54
+ config.instance_variable_set(:@api_url, ENV[ENV_API_URL] || 'https://mysigner.dev')
55
+ config.instance_variable_set(:@user_email, ENV.fetch(ENV_EMAIL, nil))
56
+ config.instance_variable_set(:@current_organization_id, org_id.to_i)
57
+ config.instance_variable_set(:@organizations, {
58
+ org_id.to_s => { 'name' => 'CI', 'token' => token }
59
+ })
60
+
61
+ config
62
+ end
63
+
64
+ # Whether this config was loaded from environment variables
65
+ def from_env?
66
+ @from_env
67
+ end
68
+
28
69
  # Get API token for current organization (or specific org)
29
70
  def api_token(org_id = nil)
30
71
  org_id ||= @current_organization_id
31
72
  return nil if org_id.nil?
32
-
73
+
33
74
  org_data = @organizations[org_id.to_s]
34
75
  token = org_data&.dig('token')
35
76
  return nil if token.nil?
36
-
77
+
37
78
  # Decrypt if encrypted
38
79
  encrypted?(token) ? decrypt_token(token) : token
39
80
  end
@@ -57,7 +98,7 @@ module Mysigner
57
98
  def org_name(org_id = nil)
58
99
  org_id ||= @current_organization_id
59
100
  return nil if org_id.nil?
60
-
101
+
61
102
  org_data = @organizations[org_id.to_s]
62
103
  org_data&.dig('name')
63
104
  end
@@ -81,17 +122,17 @@ module Mysigner
81
122
  return false unless exists?
82
123
 
83
124
  data = YAML.load_file(CONFIG_FILE)
84
-
125
+
85
126
  @api_url = data['api_url']
86
127
  @user_email = data['user_email']
87
128
  @current_organization_id = data['current_organization_id']
88
129
  @organizations = data['organizations'] || {}
89
-
130
+
90
131
  # Auto-detect encryption from config
91
132
  @encryption_enabled = encrypted_config?
92
-
133
+
93
134
  true
94
- rescue => e
135
+ rescue StandardError => e
95
136
  raise ConfigError, "Failed to load config: #{e.message}"
96
137
  end
97
138
 
@@ -107,9 +148,9 @@ module Mysigner
107
148
  }
108
149
 
109
150
  File.write(CONFIG_FILE, data.to_yaml)
110
- File.chmod(0600, CONFIG_FILE) # Make file readable only by owner
151
+ File.chmod(0o600, CONFIG_FILE) # Make file readable only by owner
111
152
  true
112
- rescue => e
153
+ rescue StandardError => e
113
154
  raise ConfigError, "Failed to save config: #{e.message}"
114
155
  end
115
156
 
@@ -119,12 +160,21 @@ module Mysigner
119
160
  @user_email = nil
120
161
  @current_organization_id = nil
121
162
  @organizations = {}
122
-
123
- if exists?
124
- File.delete(CONFIG_FILE)
125
- end
163
+
164
+ File.delete(CONFIG_FILE) if exists?
165
+
166
+ # On non-macOS the encryption key lives in a file fallback. Wipe it on
167
+ # logout so a fresh login can mint a new key — otherwise the old key
168
+ # would silently encrypt a new token that nobody else can decrypt.
169
+ FileUtils.rm_f(KEY_FILE)
170
+
171
+ # Phase 0: logout also purges the keystore cache so a shared machine
172
+ # doesn't leave prior-user keystore blobs on disk.
173
+ keystores_dir = File.expand_path('~/.mysigner/keystores')
174
+ FileUtils.rm_rf(keystores_dir)
175
+
126
176
  true
127
- rescue => e
177
+ rescue StandardError => e
128
178
  raise ConfigError, "Failed to clear config: #{e.message}"
129
179
  end
130
180
 
@@ -136,8 +186,8 @@ module Mysigner
136
186
  # Check if configuration is complete (has required fields)
137
187
  def valid?
138
188
  !@api_url.nil? && !@api_url.empty? &&
139
- !@current_organization_id.nil? &&
140
- has_token_for_org?(@current_organization_id)
189
+ !@current_organization_id.nil? &&
190
+ has_token_for_org?(@current_organization_id)
141
191
  end
142
192
 
143
193
  # Get config as hash
@@ -153,14 +203,14 @@ module Mysigner
153
203
  def display
154
204
  current_org_name = org_name(@current_organization_id) || '(not set)'
155
205
  current_token = api_token(@current_organization_id)
156
-
206
+
157
207
  display_data = {
158
208
  api_url: @api_url || '(not set)',
159
209
  user_email: @user_email || '(not set)',
160
210
  current_organization: "#{current_org_name} (ID: #{@current_organization_id || 'not set'})",
161
211
  current_token: current_token ? mask_token(current_token) : '(not set)'
162
212
  }
163
-
213
+
164
214
  # Show all organizations
165
215
  if @organizations.any?
166
216
  display_data[:all_organizations] = @organizations.map do |org_id, org_data|
@@ -168,24 +218,24 @@ module Mysigner
168
218
  "#{org_data['name']} (ID: #{org_id}) #{token_status}"
169
219
  end.join(', ')
170
220
  end
171
-
221
+
172
222
  display_data
173
223
  end
174
224
 
175
225
  # Enable encryption and re-encrypt all tokens
176
226
  def enable_encryption!
177
227
  return true if @encryption_enabled
178
-
228
+
179
229
  @encryption_enabled = true
180
-
230
+
181
231
  # Re-encrypt all existing tokens
182
- @organizations.each do |org_id, org_data|
232
+ @organizations.each_value do |org_data|
183
233
  token = org_data['token']
184
234
  next if token.nil? || encrypted?(token)
185
-
235
+
186
236
  org_data['token'] = encrypt_token(token)
187
237
  end
188
-
238
+
189
239
  save
190
240
  true
191
241
  end
@@ -193,15 +243,15 @@ module Mysigner
193
243
  # Disable encryption and decrypt all tokens
194
244
  def disable_encryption!
195
245
  return true unless @encryption_enabled
196
-
246
+
197
247
  # Decrypt all tokens first
198
- @organizations.each do |org_id, org_data|
248
+ @organizations.each_value do |org_data|
199
249
  token = org_data['token']
200
250
  next if token.nil? || !encrypted?(token)
201
-
251
+
202
252
  org_data['token'] = decrypt_token(token)
203
253
  end
204
-
254
+
205
255
  @encryption_enabled = false
206
256
  save
207
257
  true
@@ -215,12 +265,13 @@ module Mysigner
215
265
  private
216
266
 
217
267
  def ensure_config_dir_exists
218
- FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
268
+ FileUtils.mkdir_p(CONFIG_DIR)
219
269
  end
220
270
 
221
271
  def mask_token(token)
222
272
  return token if token.length < 8
223
- "#{token[0..3]}...#{token[-4..-1]}"
273
+
274
+ "#{token[0..3]}...#{token[-4..]}"
224
275
  end
225
276
 
226
277
  # Encryption methods
@@ -230,34 +281,34 @@ module Mysigner
230
281
  cipher.encrypt
231
282
  cipher.key = key
232
283
  iv = cipher.random_iv
233
-
284
+
234
285
  encrypted = cipher.update(token) + cipher.final
235
286
  auth_tag = cipher.auth_tag
236
-
287
+
237
288
  # Format: encrypted:base64(iv):base64(auth_tag):base64(encrypted_data)
238
289
  "encrypted:#{Base64.strict_encode64(iv)}:#{Base64.strict_encode64(auth_tag)}:#{Base64.strict_encode64(encrypted)}"
239
290
  end
240
291
 
241
292
  def decrypt_token(encrypted_token)
242
293
  return encrypted_token unless encrypted?(encrypted_token)
243
-
294
+
244
295
  # Parse format
245
296
  parts = encrypted_token.split(':', 4)
246
297
  return encrypted_token if parts.length != 4 || parts[0] != 'encrypted'
247
-
298
+
248
299
  iv = Base64.strict_decode64(parts[1])
249
300
  auth_tag = Base64.strict_decode64(parts[2])
250
301
  encrypted_data = Base64.strict_decode64(parts[3])
251
-
302
+
252
303
  key = get_or_create_encryption_key
253
304
  decipher = OpenSSL::Cipher.new('aes-256-gcm')
254
305
  decipher.decrypt
255
306
  decipher.key = key
256
307
  decipher.iv = iv
257
308
  decipher.auth_tag = auth_tag
258
-
309
+
259
310
  decipher.update(encrypted_data) + decipher.final
260
- rescue => e
311
+ rescue StandardError => e
261
312
  raise ConfigError, "Failed to decrypt token: #{e.message}"
262
313
  end
263
314
 
@@ -266,46 +317,96 @@ module Mysigner
266
317
  end
267
318
 
268
319
  def get_or_create_encryption_key
269
- # Try to get key from keychain
270
- key = get_key_from_keychain
320
+ # macOS Keychain is the preferred key store. On Linux/Windows we fall
321
+ # back to a 0600 file in the config dir so the encrypted YAML token
322
+ # is still recoverable across CLI invocations. The fallback is roughly
323
+ # equivalent in security to the config file itself; for the strongest
324
+ # posture in CI, prefer the MYSIGNER_API_TOKEN env var path.
325
+ if macos_keychain?
326
+ key = get_key_from_keychain
327
+ return key if key
328
+
329
+ new_key = SecureRandom.bytes(32) # 256-bit key
330
+ store_key_in_keychain(new_key)
331
+ return new_key
332
+ end
333
+
334
+ key = read_key_from_file
271
335
  return key if key
272
-
273
- # Generate new key and store in keychain
336
+
337
+ warn_keychain_unavailable_once
338
+ ensure_config_dir_exists
274
339
  new_key = SecureRandom.bytes(32) # 256-bit key
275
- store_key_in_keychain(new_key)
340
+ write_key_to_file(new_key)
276
341
  new_key
277
342
  end
278
343
 
344
+ def macos_keychain?
345
+ RbConfig::CONFIG['host_os'] =~ /darwin/i
346
+ end
347
+
279
348
  def get_key_from_keychain
280
349
  # Use macOS security command to get key from keychain
281
350
  cmd = "security find-generic-password -s '#{KEYCHAIN_SERVICE}' -a '#{KEYCHAIN_ACCOUNT}' -w 2>/dev/null"
282
351
  result = `#{cmd}`.strip
283
-
284
- return nil if result.empty? || $?.exitstatus != 0
285
-
352
+
353
+ return nil if result.empty? || $CHILD_STATUS.exitstatus != 0
354
+
286
355
  # Decode from base64
287
356
  Base64.strict_decode64(result)
288
- rescue => e
357
+ rescue StandardError
289
358
  nil
290
359
  end
291
360
 
292
361
  def store_key_in_keychain(key)
293
362
  # Encode key as base64
294
363
  encoded_key = Base64.strict_encode64(key)
295
-
364
+
296
365
  # Delete existing key if present
297
366
  `security delete-generic-password -s '#{KEYCHAIN_SERVICE}' -a '#{KEYCHAIN_ACCOUNT}' 2>/dev/null`
298
-
367
+
299
368
  # Add new key to keychain
300
369
  cmd = "security add-generic-password -s '#{KEYCHAIN_SERVICE}' -a '#{KEYCHAIN_ACCOUNT}' -w '#{encoded_key}'"
301
370
  system(cmd)
302
-
303
- $?.exitstatus == 0
304
- rescue => e
371
+
372
+ $CHILD_STATUS.exitstatus.zero?
373
+ rescue StandardError => e
305
374
  raise ConfigError, "Failed to store encryption key in keychain: #{e.message}"
306
375
  end
376
+
377
+ def read_key_from_file
378
+ return nil unless File.exist?(KEY_FILE)
379
+
380
+ encoded = File.read(KEY_FILE).strip
381
+ return nil if encoded.empty?
382
+
383
+ Base64.strict_decode64(encoded)
384
+ rescue StandardError
385
+ nil
386
+ end
387
+
388
+ def write_key_to_file(key)
389
+ File.write(KEY_FILE, Base64.strict_encode64(key))
390
+ File.chmod(0o600, KEY_FILE)
391
+ true
392
+ rescue StandardError => e
393
+ raise ConfigError, "Failed to write encryption key file: #{e.message}"
394
+ end
395
+
396
+ def warn_keychain_unavailable_once
397
+ return if defined?(@keychain_warning_shown) && @keychain_warning_shown
398
+
399
+ @keychain_warning_shown = true
400
+ return unless $stderr.respond_to?(:tty?) && $stderr.tty?
401
+
402
+ warn(<<~MSG)
403
+ [mysigner] macOS Keychain is unavailable on this platform. Falling
404
+ back to file-based encryption key at #{KEY_FILE} (mode 0600).
405
+ For the strongest CI/CD posture, set MYSIGNER_API_TOKEN as an
406
+ encrypted secret instead — env-var auth never touches the disk.
407
+ MSG
408
+ end
307
409
  end
308
410
 
309
411
  class ConfigError < StandardError; end
310
412
  end
311
-