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.
- checksums.yaml +4 -4
- data/.githooks/pre-commit +15 -0
- data/.githooks/pre-push +21 -0
- data/.github/workflows/ci.yml +29 -0
- data/.gitignore +4 -0
- data/.rubocop.yml +55 -0
- data/.rubocop_todo.yml +126 -0
- data/CHANGELOG.md +96 -0
- data/Gemfile +5 -3
- data/Gemfile.lock +38 -8
- data/README.md +14 -16
- data/Rakefile +5 -3
- data/bin/console +4 -3
- data/bin/setup +3 -0
- data/certificate_.cer +0 -0
- data/exe/mysigner +19 -2
- data/iOS_App_Store_Profile.mobileprovision +1 -0
- data/iOS_Distribution_Certificate.cer +1 -0
- data/lib/mysigner/build/android_executor.rb +83 -63
- data/lib/mysigner/build/android_parser.rb +33 -40
- data/lib/mysigner/build/configurator.rb +17 -16
- data/lib/mysigner/build/detector.rb +39 -50
- data/lib/mysigner/build/error_analyzer.rb +70 -68
- data/lib/mysigner/build/executor.rb +30 -37
- data/lib/mysigner/build/parser.rb +18 -18
- data/lib/mysigner/cleanup/private_keys_purger.rb +41 -0
- data/lib/mysigner/cli/auth_commands.rb +771 -764
- data/lib/mysigner/cli/build_commands.rb +962 -796
- data/lib/mysigner/cli/concerns/actionable_suggestions.rb +208 -154
- data/lib/mysigner/cli/concerns/api_helpers.rb +46 -54
- data/lib/mysigner/cli/concerns/error_handlers.rb +247 -237
- data/lib/mysigner/cli/concerns/helpers.rb +44 -1
- data/lib/mysigner/cli/diagnostic_commands.rb +667 -636
- data/lib/mysigner/cli/resource_commands.rb +1153 -985
- data/lib/mysigner/cli/validate_commands.rb +25 -25
- data/lib/mysigner/cli.rb +11 -1
- data/lib/mysigner/client.rb +27 -19
- data/lib/mysigner/config.rb +161 -60
- data/lib/mysigner/export/exporter.rb +38 -37
- data/lib/mysigner/signing/certificate_checker.rb +18 -23
- data/lib/mysigner/signing/gradle_signing_injector.rb +67 -0
- data/lib/mysigner/signing/keystore_manager.rb +81 -61
- data/lib/mysigner/signing/validator.rb +38 -40
- data/lib/mysigner/signing/wizard.rb +329 -342
- data/lib/mysigner/upload/app_store_automation.rb +96 -49
- data/lib/mysigner/upload/app_store_submission.rb +87 -92
- data/lib/mysigner/upload/asc_rest_uploader.rb +119 -0
- data/lib/mysigner/upload/play_store_uploader.rb +164 -144
- data/lib/mysigner/upload/uploader.rb +136 -115
- data/lib/mysigner/version.rb +3 -1
- data/lib/mysigner.rb +13 -11
- data/mysigner.gemspec +36 -33
- data/profile_.mobileprovision +0 -0
- data/test_manual.rb +37 -36
- metadata +44 -17
- 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
|
|
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
|
|
46
|
-
say
|
|
47
|
-
say
|
|
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
|
|
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
|
|
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
|
|
101
|
+
say '✓ All checks passed! Signing configuration is valid.', :green
|
|
100
102
|
else
|
|
101
|
-
say
|
|
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
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
134
|
-
say
|
|
135
|
-
say
|
|
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
|
data/lib/mysigner/client.rb
CHANGED
|
@@ -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: [
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
161
|
+
raise UnauthorizedError, 'Invalid or missing API token'
|
|
154
162
|
when Faraday::ForbiddenError
|
|
155
|
-
raise ForbiddenError,
|
|
163
|
+
raise ForbiddenError, 'Access forbidden'
|
|
156
164
|
when Faraday::ResourceNotFound
|
|
157
|
-
raise NotFoundError,
|
|
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
|
-
|
data/lib/mysigner/config.rb
CHANGED
|
@@ -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(
|
|
11
|
-
CONFIG_FILE = File.join(CONFIG_DIR,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
#
|
|
270
|
-
|
|
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
|
-
|
|
336
|
+
|
|
337
|
+
warn_keychain_unavailable_once
|
|
338
|
+
ensure_config_dir_exists
|
|
274
339
|
new_key = SecureRandom.bytes(32) # 256-bit key
|
|
275
|
-
|
|
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? ||
|
|
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
|
|
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
|
-
|
|
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
|
-
|