mysigner 0.3.4 → 0.3.7

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -2
  3. data/CHANGELOG.md +47 -0
  4. data/Gemfile +0 -1
  5. data/Gemfile.lock +2 -6
  6. data/README.md +16 -16
  7. data/lib/mysigner/build/android_executor.rb +16 -21
  8. data/lib/mysigner/build/detector.rb +3 -1
  9. data/lib/mysigner/build/executor.rb +6 -1
  10. data/lib/mysigner/cli/auth_commands.rb +14 -3
  11. data/lib/mysigner/cli/build_commands.rb +14 -11
  12. data/lib/mysigner/cli/concerns/actionable_suggestions.rb +1 -2
  13. data/lib/mysigner/cli/concerns/api_helpers.rb +16 -3
  14. data/lib/mysigner/cli/concerns/error_handlers.rb +0 -1
  15. data/lib/mysigner/cli/concerns/helpers.rb +14 -14
  16. data/lib/mysigner/cli/diagnostic_commands.rb +16 -5
  17. data/lib/mysigner/cli/resource_commands.rb +8 -1
  18. data/lib/mysigner/client.rb +52 -0
  19. data/lib/mysigner/config.rb +9 -4
  20. data/lib/mysigner/export/exporter.rb +6 -1
  21. data/lib/mysigner/formatting.rb +23 -0
  22. data/lib/mysigner/signing/certificate_checker.rb +6 -6
  23. data/lib/mysigner/signing/keystore_manager.rb +2 -0
  24. data/lib/mysigner/signing/wizard.rb +2 -0
  25. data/lib/mysigner/upload/app_store_automation.rb +13 -1
  26. data/lib/mysigner/upload/app_store_submission.rb +2 -14
  27. data/lib/mysigner/upload/asc_rest_uploader.rb +44 -3
  28. data/lib/mysigner/upload/asc_submitter.rb +5 -0
  29. data/lib/mysigner/upload/play_store_uploader.rb +2 -7
  30. data/lib/mysigner/upload/uploader.rb +9 -366
  31. data/lib/mysigner/version.rb +1 -1
  32. data/lib/mysigner.rb +1 -0
  33. data/mysigner.gemspec +6 -2
  34. metadata +2 -20
  35. data/.travis.yml +0 -7
  36. data/MANUAL_TEST.md +0 -341
  37. data/bin/console +0 -15
  38. data/bin/setup +0 -11
  39. data/test_manual.rb +0 -103
@@ -3,11 +3,45 @@
3
3
  require 'faraday'
4
4
  require 'faraday/retry'
5
5
  require 'json'
6
+ require 'uri'
6
7
 
7
8
  module Mysigner
8
9
  class Client
9
10
  attr_reader :api_url, :api_token, :user_email
10
11
 
12
+ # Hosts for which plain http is acceptable (local development). The org
13
+ # API token must NEVER be sent in cleartext to anything else.
14
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
15
+
16
+ # Refuse to attach the API token to an insecure endpoint. api_url comes
17
+ # from ~/.mysigner/config.yml or MYSIGNER_API_URL, so without this a
18
+ # poisoned config/env (or a bare-host URL the normalizer once downgraded
19
+ # to http://) would ship the Bearer token to an attacker-chosen host in
20
+ # cleartext. https is always allowed; http only for a loopback dev host.
21
+ def self.assert_secure_api_url!(url)
22
+ # A blank/nil URL means "not configured" — let the existing not-logged-in
23
+ # paths handle that rather than reporting a confusing "insecure" error.
24
+ return if url.to_s.strip.empty?
25
+
26
+ uri = begin
27
+ URI.parse(url.to_s)
28
+ rescue URI::InvalidURIError
29
+ nil
30
+ end
31
+
32
+ return if uri && uri.scheme == 'https'
33
+
34
+ # uri.hostname (unlike uri.host) already strips IPv6 brackets, so
35
+ # http://[::1]:3000 resolves to the loopback host "::1".
36
+ host = uri&.hostname.to_s.downcase
37
+ return if uri && uri.scheme == 'http' && LOOPBACK_HOSTS.include?(host)
38
+
39
+ raise InsecureUrlError,
40
+ "Refusing to send your API token over an insecure connection (#{url}). " \
41
+ 'Use an https:// URL — plain http is allowed only for localhost. ' \
42
+ 'Fix your api_url with `mysigner login` or the MYSIGNER_API_URL env var.'
43
+ end
44
+
11
45
  def initialize(api_url:, api_token:, user_email: nil)
12
46
  @api_url = api_url
13
47
  @api_token = api_token
@@ -65,7 +99,22 @@ module Mysigner
65
99
 
66
100
  # Expose connection for direct access (e.g., binary downloads)
67
101
  def connection
102
+ Client.assert_secure_api_url!(@api_url)
68
103
  @connection ||= Faraday.new(url: @api_url) do |f|
104
+ # Fail fast on a stalled connection instead of hanging the CLI
105
+ # forever. Without these the default adapter applies no timeout, so a
106
+ # server that accepts the socket but never responds blocks every
107
+ # command indefinitely and the Faraday::TimeoutError branch below is
108
+ # effectively unreachable. The read timeout is generous because the
109
+ # same client streams binary downloads.
110
+ f.options.timeout = 120
111
+ f.options.open_timeout = 10
112
+
113
+ # Assert TLS verification as an in-code invariant rather than relying
114
+ # on the adapter default — an adapter swap or stray env OpenSSL config
115
+ # could otherwise silently weaken it. (No effect on http://localhost.)
116
+ f.ssl.verify = true
117
+
69
118
  # Request middleware
70
119
  f.request :authorization, 'Bearer', @api_token
71
120
  f.request :json
@@ -192,6 +241,9 @@ module Mysigner
192
241
  class ForbiddenError < ClientError; end
193
242
  class NotFoundError < ClientError; end
194
243
  class ServerError < ClientError; end
244
+ # Raised when the API token would be sent over an insecure (non-https,
245
+ # non-loopback) connection. See Client.assert_secure_api_url!.
246
+ class InsecureUrlError < ClientError; end
195
247
 
196
248
  class ValidationError < ClientError
197
249
  def initialize(message, details = nil, suggestion: nil, error_code: nil, timestamp: nil)
@@ -212,10 +212,15 @@ module Mysigner
212
212
 
213
213
  File.delete(CONFIG_FILE) if exists?
214
214
 
215
- # On non-macOS the encryption key lives in a file fallback. Wipe it on
216
- # logout so a fresh login can mint a new key — otherwise the old key
217
- # would silently encrypt a new token that nobody else can decrypt.
218
- FileUtils.rm_f(KEY_FILE)
215
+ # Deliberately KEEP the per-machine encryption key (KEY_FILE). On
216
+ # non-macOS it also wraps any local-only credentials in the file
217
+ # fallback (~/.mysigner/credentials), which a plain logout KEEPS only
218
+ # `mysigner logout --purge` deletes them. Wiping the key here would
219
+ # leave those kept credentials permanently undecryptable. The key on its
220
+ # own is a non-secret per-machine value, and the encrypted token it
221
+ # protected is already gone with CONFIG_FILE above; re-login simply
222
+ # re-uses it. (--purge removes the credentials themselves, so no orphan
223
+ # remains in that flow either.)
219
224
 
220
225
  # Phase 0: logout also purges the keystore cache so a shared machine
221
226
  # doesn't leave prior-user keystore blobs on disk.
@@ -99,6 +99,11 @@ module Mysigner
99
99
  FileUtils.mkdir_p(@output_dir)
100
100
  puts ''
101
101
 
102
+ # Keep this an argv ARRAY (no .join(' ')). IO.popen(array) execs
103
+ # xcodebuild directly with no shell, so an archive/output path
104
+ # containing a space (e.g. ~/My Projects/App.xcarchive) or a shell
105
+ # metacharacter survives as one literal argument instead of being
106
+ # split by /bin/sh or interpreted.
102
107
  cmd = [
103
108
  'xcodebuild',
104
109
  '-exportArchive',
@@ -106,7 +111,7 @@ module Mysigner
106
111
  '-exportPath', @output_dir,
107
112
  '-exportOptionsPlist', options_plist,
108
113
  '-allowProvisioningUpdates' # Allow Xcode to update profiles if needed
109
- ].join(' ')
114
+ ]
110
115
 
111
116
  # Run command and capture output. xcodebuild output can contain
112
117
  # non-ASCII bytes (smart quotes in Apple error messages, emoji in
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mysigner
4
+ # Shared human-readable formatting helpers — single source of truth so the
5
+ # CLI concern and the upload classes don't each carry their own copy.
6
+ #
7
+ # NOTE: format_duration is deliberately NOT here. The CLI concern's
8
+ # format_duration ("2m 5s" units) and AppStoreAutomation's ("02:05" clock)
9
+ # are different formats for different displays, not duplicates.
10
+ module Formatting
11
+ module_function
12
+
13
+ def format_bytes(bytes)
14
+ if bytes < 1024
15
+ "#{bytes} B"
16
+ elsif bytes < 1024 * 1024
17
+ "#{(bytes / 1024.0).round(1)} KB"
18
+ else
19
+ "#{(bytes / (1024.0 * 1024)).round(1)} MB"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -74,9 +74,10 @@ module Mysigner
74
74
  end
75
75
 
76
76
  def get_certificate_details(name)
77
- # Get certificate in PEM format by name
78
- cmd = "security find-certificate -c \"#{name}\" -p"
79
- stdout, _, status = Open3.capture3(cmd)
77
+ # Get certificate in PEM format by name. argv form (no shell) so a
78
+ # certificate CN containing $(...) / backticks — which a crafted
79
+ # imported keychain item can carry — is passed literally, not executed.
80
+ stdout, _, status = Open3.capture3('security', 'find-certificate', '-c', name, '-p')
80
81
 
81
82
  # If not found, return nil
82
83
  return nil if !status.success? || stdout.empty?
@@ -88,9 +89,8 @@ module Mysigner
88
89
  temp.write(stdout)
89
90
  temp.close
90
91
 
91
- # Get expiry date using openssl
92
- cmd = "openssl x509 -in #{temp.path} -noout -enddate"
93
- out, _, stat = Open3.capture3(cmd)
92
+ # Get expiry date using openssl (argv form, no shell)
93
+ out, _, stat = Open3.capture3('openssl', 'x509', '-in', temp.path, '-noout', '-enddate')
94
94
 
95
95
  if stat.success? && out =~ /notAfter=(.+)/
96
96
  expiry_str = ::Regexp.last_match(1).strip
@@ -244,6 +244,8 @@ module Mysigner
244
244
  config = Mysigner::Config.new
245
245
  config.load if config.exists?
246
246
 
247
+ # Never attach the API token to a non-https (non-loopback) endpoint.
248
+ Mysigner::Client.assert_secure_api_url!(config.api_url)
247
249
  Faraday.new(url: config.api_url) do |f|
248
250
  f.request :authorization, 'Bearer', config.api_token
249
251
  f.options.timeout = 60
@@ -414,6 +414,8 @@ module Mysigner
414
414
  # (the client's get method uses JSON middleware which corrupts binary data)
415
415
  download_url = "/api/v1/organizations/#{@organization_id}/profiles/#{profile['id']}/download"
416
416
 
417
+ # Never attach the API token to a non-https (non-loopback) endpoint.
418
+ Mysigner::Client.assert_secure_api_url!(@client.api_url)
417
419
  conn = Faraday.new(url: @client.api_url) do |f|
418
420
  f.request :authorization, 'Bearer', @client.api_token
419
421
  f.adapter Faraday.default_adapter
@@ -150,7 +150,7 @@ module Mysigner
150
150
 
151
151
  if elapsed >= @timeout
152
152
  status[:timed_out] = true
153
- puts "\r✗ Timed out after #{format_duration(elapsed)} (use --asc-timeout-seconds to extend)".ljust(90)
153
+ puts "\r✗ Timed out after #{format_duration(elapsed)} the build is still processing on Apple's side".ljust(90)
154
154
  puts ''
155
155
  return [build, status]
156
156
  end
@@ -160,6 +160,18 @@ module Mysigner
160
160
  $stdout.flush
161
161
  sleep @poll_interval
162
162
  end
163
+ rescue Interrupt
164
+ # Only the polling loop draws the "Waiting …" line, so only it needs the
165
+ # resume hint; if we weren't actually waiting, let Ctrl-C propagate.
166
+ raise unless @wait_enabled
167
+
168
+ # Ctrl-C during the wait: don't leave a half-drawn "Waiting …" line with
169
+ # no newline. The build keeps processing on Apple's side — tell the user
170
+ # how to resume, then exit with the conventional SIGINT code.
171
+ puts ''
172
+ puts '⏹ Stopped waiting — your build is still processing on Apple’s side.'
173
+ puts ' Re-run `mysigner ship appstore --wait` to resume watching.'
174
+ exit 130
163
175
  end
164
176
 
165
177
  def latest_build(app_id, build_info)
@@ -91,18 +91,6 @@ module Mysigner
91
91
  nil
92
92
  end
93
93
 
94
- # Fetch release config and extract min_build_number for smart build selection
95
- def fetch_release_config
96
- metadata = fetch_release_metadata
97
- return {} unless metadata
98
-
99
- config = {}
100
- config[:min_build_number] = metadata['build_number'].to_i if metadata['build_number']
101
- config[:release_type] = metadata['release_type'] if metadata['release_type']
102
- config[:earliest_release_date] = metadata['earliest_release_date'] if metadata['earliest_release_date']
103
- config
104
- end
105
-
106
94
  def merge_metadata(api_metadata)
107
95
  api_data = stringify_keys(api_metadata || {})
108
96
  overrides = stringify_keys(@metadata_overrides)
@@ -133,8 +121,8 @@ module Mysigner
133
121
  puts '💡 Auto-submit enabled' if metadata && metadata['auto_submit']
134
122
 
135
123
  puts ''
136
- puts 'Tip: rerun with --submit-for-review when ready'
137
- puts ' Use --wait/--asc-timeout-seconds to control polling'
124
+ puts 'Tip: `mysigner ship appstore` uploads AND submits for review automatically.'
125
+ puts ' Pass --no-auto-submit to upload only, or --wait to watch processing.'
138
126
  puts ''
139
127
  end
140
128
 
@@ -37,6 +37,7 @@ module Mysigner
37
37
  POLL_INTERVAL = 10
38
38
  POLL_TIMEOUT = 600
39
39
  CHUNK_RETRIES = 2
40
+ DIGEST_READ_BYTES = 1024 * 1024 # 1 MiB streaming buffer for the digest pass
40
41
 
41
42
  APPLE_ASC_BASE = 'https://api.appstoreconnect.apple.com'
42
43
 
@@ -66,6 +67,9 @@ module Mysigner
66
67
  # args (which preserves the Keychain-only behavior the existing specs
67
68
  # pin).
68
69
  @asc_creds = asc_creds
70
+ # Path of a .p8 WE materialized this run (set by ensure_p8_in_apple_dir!),
71
+ # deleted by cleanup_materialized_p8! after altool. nil = nothing to clean.
72
+ @materialized_p8_path = nil
69
73
  end
70
74
 
71
75
  def call
@@ -77,8 +81,7 @@ module Mysigner
77
81
  ops.each { |op| put_chunk_with_retry(f, op) }
78
82
  end
79
83
 
80
- md5 = Digest::MD5.file(@ipa_path).hexdigest
81
- sha = Digest::SHA256.file(@ipa_path).hexdigest
84
+ md5, sha = compute_file_digests(@ipa_path)
82
85
 
83
86
  mark_uploaded_via_server(build_upload_id, md5: md5, sha: sha)
84
87
 
@@ -88,6 +91,21 @@ module Mysigner
88
91
 
89
92
  private
90
93
 
94
+ # Compute MD5 + SHA-256 in a SINGLE streamed pass over the IPA. The old
95
+ # code called Digest::MD5.file then Digest::SHA256.file, reading the whole
96
+ # (often hundreds-of-MB) file end-to-end twice; this reads it once.
97
+ def compute_file_digests(path)
98
+ md5 = Digest::MD5.new
99
+ sha = Digest::SHA256.new
100
+ File.open(path, 'rb') do |f|
101
+ while (chunk = f.read(DIGEST_READ_BYTES))
102
+ md5.update(chunk)
103
+ sha.update(chunk)
104
+ end
105
+ end
106
+ [md5.hexdigest, sha.hexdigest]
107
+ end
108
+
91
109
  # Local-only: shell out to `xcrun altool --upload-app`. altool is
92
110
  # Apple's canonical CLI for App Store uploads — it handles the multi-
93
111
  # step buildUploads/buildUploadFiles/chunk-PUT/commit dance correctly
@@ -110,6 +128,12 @@ module Mysigner
110
128
  return { build_upload_id: nil, final_state: 'COMPLETE' } if status.success?
111
129
 
112
130
  raise_altool_failure!(stdout_stderr)
131
+ ensure
132
+ # Never leave the plaintext ASC private key sitting at Apple's
133
+ # well-known discovery path after the upload — delete the one WE
134
+ # materialized this run (cleanup_materialized_p8! is a no-op when the
135
+ # user already had their own key there).
136
+ cleanup_materialized_p8!
113
137
  end
114
138
 
115
139
  # `xcrun altool --upload-app --file ... --type ios --apiKey KEY_ID
@@ -148,17 +172,34 @@ module Mysigner
148
172
  def ensure_p8_in_apple_dir!(creds)
149
173
  FileUtils.mkdir_p(APPLE_PRIVATE_KEYS_DIR, mode: 0o700)
150
174
  target = File.join(APPLE_PRIVATE_KEYS_DIR, "AuthKey_#{creds.key_id}.p8")
175
+ pre_existing = File.exist?(target)
151
176
 
152
- if File.exist?(target) && File.read(target) == creds.p8_pem
177
+ if pre_existing && File.read(target) == creds.p8_pem
153
178
  File.chmod(0o600, target)
179
+ # The user already had this exact key here — leave it untouched.
180
+ @materialized_p8_path = nil
154
181
  return target
155
182
  end
156
183
 
184
+ # Mark for cleanup BEFORE writing, so a failure between the write and
185
+ # the chmod still lets the ensure block remove the freshly-written
186
+ # plaintext key. Only when we created it fresh — if a (different) file
187
+ # pre-existed we must not delete the key the user manages themselves.
188
+ @materialized_p8_path = pre_existing ? nil : target
157
189
  File.write(target, creds.p8_pem)
158
190
  File.chmod(0o600, target)
159
191
  target
160
192
  end
161
193
 
194
+ # Remove the .p8 we materialized for altool this run, if any. Idempotent
195
+ # and best-effort (a failure to delete must not mask the upload result).
196
+ def cleanup_materialized_p8!
197
+ return unless @materialized_p8_path
198
+
199
+ FileUtils.rm_f(@materialized_p8_path)
200
+ @materialized_p8_path = nil
201
+ end
202
+
162
203
  # Parse altool's --output-format json blob. The error path is:
163
204
  # { "product-errors": [ { "code": ..., "message": "..." }, ... ] }
164
205
  # Any "build version already exists" diagnostic — Apple's ITMS-90... —
@@ -379,6 +379,11 @@ module Mysigner
379
379
 
380
380
  def http_conn
381
381
  @http_conn ||= Faraday.new(url: APPLE_ASC_BASE) do |f|
382
+ # Explicit timeouts so a stalled Apple connection can't hang the
383
+ # 30-minute poll loop forever (the per-request rescue only fires on
384
+ # an actual error, not a silent stall).
385
+ f.options.timeout = 60
386
+ f.options.open_timeout = 10
382
387
  f.adapter Faraday.default_adapter
383
388
  end
384
389
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'mysigner/formatting'
4
5
 
5
6
  module Mysigner
6
7
  module Upload
@@ -477,13 +478,7 @@ module Mysigner
477
478
  end
478
479
 
479
480
  def format_bytes(bytes)
480
- if bytes < 1024
481
- "#{bytes} B"
482
- elsif bytes < 1024 * 1024
483
- "#{(bytes / 1024.0).round(1)} KB"
484
- else
485
- "#{(bytes / (1024.0 * 1024)).round(1)} MB"
486
- end
481
+ Mysigner::Formatting.format_bytes(bytes)
487
482
  end
488
483
  end
489
484
  end