mysigner 0.1.1 → 0.1.3
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 +1 -0
- data/.rubocop.yml +55 -0
- data/.rubocop_todo.yml +112 -0
- data/CHANGELOG.md +96 -0
- data/Gemfile +5 -3
- data/Gemfile.lock +38 -8
- data/README.md +87 -17
- data/Rakefile +5 -3
- data/bin/console +4 -3
- data/bin/setup +3 -0
- data/exe/mysigner +2 -1
- data/lib/mysigner/build/android_executor.rb +46 -52
- 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/cli/auth_commands.rb +735 -752
- data/lib/mysigner/cli/build_commands.rb +697 -721
- 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 +12 -1
- data/lib/mysigner/cli/diagnostic_commands.rb +659 -635
- data/lib/mysigner/cli/resource_commands.rb +1266 -822
- data/lib/mysigner/cli/validate_commands.rb +161 -0
- data/lib/mysigner/cli.rb +5 -1
- data/lib/mysigner/client.rb +27 -19
- data/lib/mysigner/config.rb +93 -56
- data/lib/mysigner/export/exporter.rb +32 -36
- data/lib/mysigner/signing/certificate_checker.rb +18 -23
- data/lib/mysigner/signing/keystore_manager.rb +34 -39
- data/lib/mysigner/signing/validator.rb +38 -40
- data/lib/mysigner/signing/wizard.rb +329 -342
- data/lib/mysigner/upload/app_store_automation.rb +51 -49
- data/lib/mysigner/upload/app_store_submission.rb +87 -92
- data/lib/mysigner/upload/play_store_uploader.rb +98 -115
- data/lib/mysigner/upload/uploader.rb +101 -109
- data/lib/mysigner/version.rb +3 -1
- data/lib/mysigner.rb +13 -11
- data/mysigner.gemspec +36 -33
- data/test_manual.rb +37 -36
- metadata +38 -16
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mysigner
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
module ValidateCommands
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.class_eval do
|
|
8
|
+
desc 'validate', 'Validate signing configuration on the server'
|
|
9
|
+
long_desc <<~DESC
|
|
10
|
+
Check if your bundle ID, certificate, and provisioning profile exist and
|
|
11
|
+
are valid on the My Signer server.
|
|
12
|
+
|
|
13
|
+
WHY VALIDATE?
|
|
14
|
+
|
|
15
|
+
The CLI does local keychain/certificate validation, but doesn't check if
|
|
16
|
+
your signing assets exist on the server. This catches "forgot to sync"
|
|
17
|
+
or "profile expired" errors before a build starts.
|
|
18
|
+
|
|
19
|
+
OPTIONS:
|
|
20
|
+
|
|
21
|
+
--bundle-id / -b Bundle identifier (e.g., com.example.app)
|
|
22
|
+
Auto-detected from Xcode project if not provided
|
|
23
|
+
|
|
24
|
+
--type / -t Signing type: development, appstore, adhoc, inhouse
|
|
25
|
+
|
|
26
|
+
EXAMPLES:
|
|
27
|
+
|
|
28
|
+
# Validate development signing for an app
|
|
29
|
+
mysigner validate --bundle-id com.example.app --type development
|
|
30
|
+
|
|
31
|
+
# Validate App Store signing
|
|
32
|
+
mysigner validate -b com.example.app -t appstore
|
|
33
|
+
|
|
34
|
+
# Auto-detect bundle ID from current project
|
|
35
|
+
mysigner validate --type development
|
|
36
|
+
DESC
|
|
37
|
+
method_option :bundle_id, type: :string, aliases: '-b', desc: 'Bundle identifier (e.g., com.example.app)'
|
|
38
|
+
method_option :type, type: :string, aliases: '-t', desc: 'Signing type: development, appstore, adhoc, inhouse'
|
|
39
|
+
def validate
|
|
40
|
+
config = load_config
|
|
41
|
+
client = create_client(config)
|
|
42
|
+
|
|
43
|
+
bundle_id = options[:bundle_id] || detect_bundle_id_from_project
|
|
44
|
+
signing_type = options[:type]
|
|
45
|
+
|
|
46
|
+
unless bundle_id
|
|
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
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless signing_type
|
|
54
|
+
error 'Signing type is required. Use --type with one of: development, appstore, adhoc, inhouse'
|
|
55
|
+
say ''
|
|
56
|
+
say "Example: mysigner validate --bundle-id #{bundle_id} --type development", :yellow
|
|
57
|
+
exit 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
valid_types = %w[development appstore adhoc inhouse]
|
|
61
|
+
unless valid_types.include?(signing_type)
|
|
62
|
+
error "Invalid signing type: #{signing_type}"
|
|
63
|
+
say "Valid types: #{valid_types.join(', ')}", :yellow
|
|
64
|
+
exit 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
say '🔍 Validating signing configuration...', :cyan
|
|
68
|
+
say ''
|
|
69
|
+
say " Bundle ID: #{bundle_id}", :white
|
|
70
|
+
say " Type: #{signing_type}", :white
|
|
71
|
+
say ''
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
response = client.post(
|
|
75
|
+
"/api/v1/organizations/#{config.current_organization_id}/validate",
|
|
76
|
+
body: {
|
|
77
|
+
bundle_id: bundle_id,
|
|
78
|
+
type: signing_type
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
result = response[:data]
|
|
83
|
+
checks = result['checks'] || {}
|
|
84
|
+
valid = result['valid']
|
|
85
|
+
|
|
86
|
+
# Display each check
|
|
87
|
+
%w[bundle_id certificate profile].each do |check_name|
|
|
88
|
+
check = checks[check_name]
|
|
89
|
+
next unless check
|
|
90
|
+
|
|
91
|
+
if check['status'] == 'pass'
|
|
92
|
+
say " ✓ #{check_name.tr('_', ' ').capitalize}: #{check['message']}", :green
|
|
93
|
+
else
|
|
94
|
+
say " ✗ #{check_name.tr('_', ' ').capitalize}: #{check['message']}", :red
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
say ''
|
|
99
|
+
|
|
100
|
+
if valid
|
|
101
|
+
say '✓ All checks passed! Signing configuration is valid.', :green
|
|
102
|
+
else
|
|
103
|
+
say '✗ Validation failed. Some checks did not pass.', :red
|
|
104
|
+
|
|
105
|
+
suggestions = result['suggestions'] || []
|
|
106
|
+
if suggestions.any?
|
|
107
|
+
say ''
|
|
108
|
+
say '💡 Suggestions:', :cyan
|
|
109
|
+
suggestions.each do |suggestion|
|
|
110
|
+
say " → #{suggestion}", :yellow
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
exit 1
|
|
115
|
+
end
|
|
116
|
+
rescue Mysigner::NotFoundError => e
|
|
117
|
+
error "Not found: #{e.message}"
|
|
118
|
+
say ''
|
|
119
|
+
say '💡 Make sure your bundle ID is synced:', :cyan
|
|
120
|
+
say " → Run 'mysigner sync ios' to sync from Apple Developer Portal", :yellow
|
|
121
|
+
say " → Run 'mysigner bundleid list' to list registered bundle IDs", :yellow
|
|
122
|
+
exit 1
|
|
123
|
+
rescue Mysigner::ValidationError => e
|
|
124
|
+
error "Validation error: #{e.message}"
|
|
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
|
+
end
|
|
129
|
+
exit 1
|
|
130
|
+
rescue Mysigner::ClientError => e
|
|
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
|
|
136
|
+
exit 1
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def detect_bundle_id_from_project
|
|
143
|
+
# Try to find bundle ID from Xcode project in current directory
|
|
144
|
+
pbxproj_files = Dir.glob('**/*.pbxproj')
|
|
145
|
+
return nil if pbxproj_files.empty?
|
|
146
|
+
|
|
147
|
+
pbxproj_files.each do |file|
|
|
148
|
+
content = File.read(file)
|
|
149
|
+
match = content.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^;"]+)"?/)
|
|
150
|
+
return match[1].strip if match
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
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'
|
|
@@ -11,6 +13,7 @@ require_relative 'cli/auth_commands'
|
|
|
11
13
|
require_relative 'cli/diagnostic_commands'
|
|
12
14
|
require_relative 'cli/build_commands'
|
|
13
15
|
require_relative 'cli/resource_commands'
|
|
16
|
+
require_relative 'cli/validate_commands'
|
|
14
17
|
|
|
15
18
|
module Mysigner
|
|
16
19
|
class CLI < Thor
|
|
@@ -31,6 +34,7 @@ module Mysigner
|
|
|
31
34
|
include DiagnosticCommands
|
|
32
35
|
include BuildCommands
|
|
33
36
|
include ResourceCommands
|
|
37
|
+
include ValidateCommands
|
|
34
38
|
|
|
35
39
|
# Command aliases for power users
|
|
36
40
|
map 's' => :ship
|
|
@@ -40,4 +44,4 @@ module Mysigner
|
|
|
40
44
|
map 'st' => :status
|
|
41
45
|
map 'd' => :doctor
|
|
42
46
|
end
|
|
43
|
-
end
|
|
47
|
+
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,3 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
4
|
+
|
|
1
5
|
require 'yaml'
|
|
2
6
|
require 'fileutils'
|
|
3
7
|
require 'openssl'
|
|
@@ -7,33 +11,68 @@ require 'securerandom'
|
|
|
7
11
|
|
|
8
12
|
module Mysigner
|
|
9
13
|
class Config
|
|
10
|
-
CONFIG_DIR = File.expand_path(
|
|
11
|
-
CONFIG_FILE = File.join(CONFIG_DIR,
|
|
12
|
-
KEYCHAIN_SERVICE =
|
|
13
|
-
KEYCHAIN_ACCOUNT =
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
CONFIG_DIR = File.expand_path('~/.mysigner').freeze
|
|
15
|
+
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml').freeze
|
|
16
|
+
KEYCHAIN_SERVICE = 'com.mysigner.cli'
|
|
17
|
+
KEYCHAIN_ACCOUNT = 'config_encryption_key'
|
|
18
|
+
|
|
19
|
+
# Environment variable names for CI/CD support
|
|
20
|
+
ENV_API_TOKEN = 'MYSIGNER_API_TOKEN'
|
|
21
|
+
ENV_API_URL = 'MYSIGNER_API_URL'
|
|
22
|
+
ENV_EMAIL = 'MYSIGNER_EMAIL'
|
|
23
|
+
ENV_ORG_ID = 'MYSIGNER_ORG_ID'
|
|
24
|
+
|
|
25
|
+
attr_accessor :api_url, :user_email, :current_organization_id, :encryption_enabled
|
|
16
26
|
attr_reader :organizations
|
|
17
|
-
attr_accessor :encryption_enabled
|
|
18
27
|
|
|
19
28
|
def initialize
|
|
20
29
|
@api_url = nil
|
|
21
30
|
@user_email = nil
|
|
22
31
|
@current_organization_id = nil
|
|
23
32
|
@organizations = {}
|
|
24
|
-
@encryption_enabled = true
|
|
33
|
+
@encryption_enabled = true # Enable by default for security
|
|
34
|
+
@from_env = false
|
|
25
35
|
load if exists?
|
|
26
36
|
end
|
|
27
37
|
|
|
38
|
+
# Check if all required env vars are set for CI/CD mode
|
|
39
|
+
def self.env_configured?
|
|
40
|
+
ENV.fetch(ENV_API_TOKEN, nil) && !ENV[ENV_API_TOKEN].empty? &&
|
|
41
|
+
ENV.fetch(ENV_ORG_ID, nil) && !ENV[ENV_ORG_ID].empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Create a Config from environment variables (for CI/CD)
|
|
45
|
+
def self.from_env
|
|
46
|
+
config = allocate
|
|
47
|
+
config.instance_variable_set(:@encryption_enabled, false)
|
|
48
|
+
config.instance_variable_set(:@from_env, true)
|
|
49
|
+
|
|
50
|
+
org_id = ENV.fetch(ENV_ORG_ID, nil)
|
|
51
|
+
token = ENV.fetch(ENV_API_TOKEN, nil)
|
|
52
|
+
config.instance_variable_set(:@api_url, ENV[ENV_API_URL] || 'https://mysigner.dev')
|
|
53
|
+
config.instance_variable_set(:@user_email, ENV.fetch(ENV_EMAIL, nil))
|
|
54
|
+
config.instance_variable_set(:@current_organization_id, org_id.to_i)
|
|
55
|
+
config.instance_variable_set(:@organizations, {
|
|
56
|
+
org_id.to_s => { 'name' => 'CI', 'token' => token }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
config
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Whether this config was loaded from environment variables
|
|
63
|
+
def from_env?
|
|
64
|
+
@from_env
|
|
65
|
+
end
|
|
66
|
+
|
|
28
67
|
# Get API token for current organization (or specific org)
|
|
29
68
|
def api_token(org_id = nil)
|
|
30
69
|
org_id ||= @current_organization_id
|
|
31
70
|
return nil if org_id.nil?
|
|
32
|
-
|
|
71
|
+
|
|
33
72
|
org_data = @organizations[org_id.to_s]
|
|
34
73
|
token = org_data&.dig('token')
|
|
35
74
|
return nil if token.nil?
|
|
36
|
-
|
|
75
|
+
|
|
37
76
|
# Decrypt if encrypted
|
|
38
77
|
encrypted?(token) ? decrypt_token(token) : token
|
|
39
78
|
end
|
|
@@ -57,7 +96,7 @@ module Mysigner
|
|
|
57
96
|
def org_name(org_id = nil)
|
|
58
97
|
org_id ||= @current_organization_id
|
|
59
98
|
return nil if org_id.nil?
|
|
60
|
-
|
|
99
|
+
|
|
61
100
|
org_data = @organizations[org_id.to_s]
|
|
62
101
|
org_data&.dig('name')
|
|
63
102
|
end
|
|
@@ -81,17 +120,17 @@ module Mysigner
|
|
|
81
120
|
return false unless exists?
|
|
82
121
|
|
|
83
122
|
data = YAML.load_file(CONFIG_FILE)
|
|
84
|
-
|
|
123
|
+
|
|
85
124
|
@api_url = data['api_url']
|
|
86
125
|
@user_email = data['user_email']
|
|
87
126
|
@current_organization_id = data['current_organization_id']
|
|
88
127
|
@organizations = data['organizations'] || {}
|
|
89
|
-
|
|
128
|
+
|
|
90
129
|
# Auto-detect encryption from config
|
|
91
130
|
@encryption_enabled = encrypted_config?
|
|
92
|
-
|
|
131
|
+
|
|
93
132
|
true
|
|
94
|
-
rescue => e
|
|
133
|
+
rescue StandardError => e
|
|
95
134
|
raise ConfigError, "Failed to load config: #{e.message}"
|
|
96
135
|
end
|
|
97
136
|
|
|
@@ -107,9 +146,9 @@ module Mysigner
|
|
|
107
146
|
}
|
|
108
147
|
|
|
109
148
|
File.write(CONFIG_FILE, data.to_yaml)
|
|
110
|
-
File.chmod(
|
|
149
|
+
File.chmod(0o600, CONFIG_FILE) # Make file readable only by owner
|
|
111
150
|
true
|
|
112
|
-
rescue => e
|
|
151
|
+
rescue StandardError => e
|
|
113
152
|
raise ConfigError, "Failed to save config: #{e.message}"
|
|
114
153
|
end
|
|
115
154
|
|
|
@@ -119,12 +158,10 @@ module Mysigner
|
|
|
119
158
|
@user_email = nil
|
|
120
159
|
@current_organization_id = nil
|
|
121
160
|
@organizations = {}
|
|
122
|
-
|
|
123
|
-
if exists?
|
|
124
|
-
File.delete(CONFIG_FILE)
|
|
125
|
-
end
|
|
161
|
+
|
|
162
|
+
File.delete(CONFIG_FILE) if exists?
|
|
126
163
|
true
|
|
127
|
-
rescue => e
|
|
164
|
+
rescue StandardError => e
|
|
128
165
|
raise ConfigError, "Failed to clear config: #{e.message}"
|
|
129
166
|
end
|
|
130
167
|
|
|
@@ -136,8 +173,8 @@ module Mysigner
|
|
|
136
173
|
# Check if configuration is complete (has required fields)
|
|
137
174
|
def valid?
|
|
138
175
|
!@api_url.nil? && !@api_url.empty? &&
|
|
139
|
-
|
|
140
|
-
|
|
176
|
+
!@current_organization_id.nil? &&
|
|
177
|
+
has_token_for_org?(@current_organization_id)
|
|
141
178
|
end
|
|
142
179
|
|
|
143
180
|
# Get config as hash
|
|
@@ -153,14 +190,14 @@ module Mysigner
|
|
|
153
190
|
def display
|
|
154
191
|
current_org_name = org_name(@current_organization_id) || '(not set)'
|
|
155
192
|
current_token = api_token(@current_organization_id)
|
|
156
|
-
|
|
193
|
+
|
|
157
194
|
display_data = {
|
|
158
195
|
api_url: @api_url || '(not set)',
|
|
159
196
|
user_email: @user_email || '(not set)',
|
|
160
197
|
current_organization: "#{current_org_name} (ID: #{@current_organization_id || 'not set'})",
|
|
161
198
|
current_token: current_token ? mask_token(current_token) : '(not set)'
|
|
162
199
|
}
|
|
163
|
-
|
|
200
|
+
|
|
164
201
|
# Show all organizations
|
|
165
202
|
if @organizations.any?
|
|
166
203
|
display_data[:all_organizations] = @organizations.map do |org_id, org_data|
|
|
@@ -168,24 +205,24 @@ module Mysigner
|
|
|
168
205
|
"#{org_data['name']} (ID: #{org_id}) #{token_status}"
|
|
169
206
|
end.join(', ')
|
|
170
207
|
end
|
|
171
|
-
|
|
208
|
+
|
|
172
209
|
display_data
|
|
173
210
|
end
|
|
174
211
|
|
|
175
212
|
# Enable encryption and re-encrypt all tokens
|
|
176
213
|
def enable_encryption!
|
|
177
214
|
return true if @encryption_enabled
|
|
178
|
-
|
|
215
|
+
|
|
179
216
|
@encryption_enabled = true
|
|
180
|
-
|
|
217
|
+
|
|
181
218
|
# Re-encrypt all existing tokens
|
|
182
|
-
@organizations.
|
|
219
|
+
@organizations.each_value do |org_data|
|
|
183
220
|
token = org_data['token']
|
|
184
221
|
next if token.nil? || encrypted?(token)
|
|
185
|
-
|
|
222
|
+
|
|
186
223
|
org_data['token'] = encrypt_token(token)
|
|
187
224
|
end
|
|
188
|
-
|
|
225
|
+
|
|
189
226
|
save
|
|
190
227
|
true
|
|
191
228
|
end
|
|
@@ -193,15 +230,15 @@ module Mysigner
|
|
|
193
230
|
# Disable encryption and decrypt all tokens
|
|
194
231
|
def disable_encryption!
|
|
195
232
|
return true unless @encryption_enabled
|
|
196
|
-
|
|
233
|
+
|
|
197
234
|
# Decrypt all tokens first
|
|
198
|
-
@organizations.
|
|
235
|
+
@organizations.each_value do |org_data|
|
|
199
236
|
token = org_data['token']
|
|
200
237
|
next if token.nil? || !encrypted?(token)
|
|
201
|
-
|
|
238
|
+
|
|
202
239
|
org_data['token'] = decrypt_token(token)
|
|
203
240
|
end
|
|
204
|
-
|
|
241
|
+
|
|
205
242
|
@encryption_enabled = false
|
|
206
243
|
save
|
|
207
244
|
true
|
|
@@ -215,12 +252,13 @@ module Mysigner
|
|
|
215
252
|
private
|
|
216
253
|
|
|
217
254
|
def ensure_config_dir_exists
|
|
218
|
-
FileUtils.mkdir_p(CONFIG_DIR)
|
|
255
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
219
256
|
end
|
|
220
257
|
|
|
221
258
|
def mask_token(token)
|
|
222
259
|
return token if token.length < 8
|
|
223
|
-
|
|
260
|
+
|
|
261
|
+
"#{token[0..3]}...#{token[-4..]}"
|
|
224
262
|
end
|
|
225
263
|
|
|
226
264
|
# Encryption methods
|
|
@@ -230,34 +268,34 @@ module Mysigner
|
|
|
230
268
|
cipher.encrypt
|
|
231
269
|
cipher.key = key
|
|
232
270
|
iv = cipher.random_iv
|
|
233
|
-
|
|
271
|
+
|
|
234
272
|
encrypted = cipher.update(token) + cipher.final
|
|
235
273
|
auth_tag = cipher.auth_tag
|
|
236
|
-
|
|
274
|
+
|
|
237
275
|
# Format: encrypted:base64(iv):base64(auth_tag):base64(encrypted_data)
|
|
238
276
|
"encrypted:#{Base64.strict_encode64(iv)}:#{Base64.strict_encode64(auth_tag)}:#{Base64.strict_encode64(encrypted)}"
|
|
239
277
|
end
|
|
240
278
|
|
|
241
279
|
def decrypt_token(encrypted_token)
|
|
242
280
|
return encrypted_token unless encrypted?(encrypted_token)
|
|
243
|
-
|
|
281
|
+
|
|
244
282
|
# Parse format
|
|
245
283
|
parts = encrypted_token.split(':', 4)
|
|
246
284
|
return encrypted_token if parts.length != 4 || parts[0] != 'encrypted'
|
|
247
|
-
|
|
285
|
+
|
|
248
286
|
iv = Base64.strict_decode64(parts[1])
|
|
249
287
|
auth_tag = Base64.strict_decode64(parts[2])
|
|
250
288
|
encrypted_data = Base64.strict_decode64(parts[3])
|
|
251
|
-
|
|
289
|
+
|
|
252
290
|
key = get_or_create_encryption_key
|
|
253
291
|
decipher = OpenSSL::Cipher.new('aes-256-gcm')
|
|
254
292
|
decipher.decrypt
|
|
255
293
|
decipher.key = key
|
|
256
294
|
decipher.iv = iv
|
|
257
295
|
decipher.auth_tag = auth_tag
|
|
258
|
-
|
|
296
|
+
|
|
259
297
|
decipher.update(encrypted_data) + decipher.final
|
|
260
|
-
rescue => e
|
|
298
|
+
rescue StandardError => e
|
|
261
299
|
raise ConfigError, "Failed to decrypt token: #{e.message}"
|
|
262
300
|
end
|
|
263
301
|
|
|
@@ -269,7 +307,7 @@ module Mysigner
|
|
|
269
307
|
# Try to get key from keychain
|
|
270
308
|
key = get_key_from_keychain
|
|
271
309
|
return key if key
|
|
272
|
-
|
|
310
|
+
|
|
273
311
|
# Generate new key and store in keychain
|
|
274
312
|
new_key = SecureRandom.bytes(32) # 256-bit key
|
|
275
313
|
store_key_in_keychain(new_key)
|
|
@@ -280,32 +318,31 @@ module Mysigner
|
|
|
280
318
|
# Use macOS security command to get key from keychain
|
|
281
319
|
cmd = "security find-generic-password -s '#{KEYCHAIN_SERVICE}' -a '#{KEYCHAIN_ACCOUNT}' -w 2>/dev/null"
|
|
282
320
|
result = `#{cmd}`.strip
|
|
283
|
-
|
|
284
|
-
return nil if result.empty? ||
|
|
285
|
-
|
|
321
|
+
|
|
322
|
+
return nil if result.empty? || $CHILD_STATUS.exitstatus != 0
|
|
323
|
+
|
|
286
324
|
# Decode from base64
|
|
287
325
|
Base64.strict_decode64(result)
|
|
288
|
-
rescue
|
|
326
|
+
rescue StandardError
|
|
289
327
|
nil
|
|
290
328
|
end
|
|
291
329
|
|
|
292
330
|
def store_key_in_keychain(key)
|
|
293
331
|
# Encode key as base64
|
|
294
332
|
encoded_key = Base64.strict_encode64(key)
|
|
295
|
-
|
|
333
|
+
|
|
296
334
|
# Delete existing key if present
|
|
297
335
|
`security delete-generic-password -s '#{KEYCHAIN_SERVICE}' -a '#{KEYCHAIN_ACCOUNT}' 2>/dev/null`
|
|
298
|
-
|
|
336
|
+
|
|
299
337
|
# Add new key to keychain
|
|
300
338
|
cmd = "security add-generic-password -s '#{KEYCHAIN_SERVICE}' -a '#{KEYCHAIN_ACCOUNT}' -w '#{encoded_key}'"
|
|
301
339
|
system(cmd)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
rescue => e
|
|
340
|
+
|
|
341
|
+
$CHILD_STATUS.exitstatus.zero?
|
|
342
|
+
rescue StandardError => e
|
|
305
343
|
raise ConfigError, "Failed to store encryption key in keychain: #{e.message}"
|
|
306
344
|
end
|
|
307
345
|
end
|
|
308
346
|
|
|
309
347
|
class ConfigError < StandardError; end
|
|
310
348
|
end
|
|
311
|
-
|