mysigner 0.1.3 → 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.
@@ -8,11 +8,13 @@ require 'openssl'
8
8
  require 'base64'
9
9
  require 'json'
10
10
  require 'securerandom'
11
+ require 'rbconfig'
11
12
 
12
13
  module Mysigner
13
14
  class Config
14
15
  CONFIG_DIR = File.expand_path('~/.mysigner').freeze
15
16
  CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml').freeze
17
+ KEY_FILE = File.join(CONFIG_DIR, '.encryption_key').freeze
16
18
  KEYCHAIN_SERVICE = 'com.mysigner.cli'
17
19
  KEYCHAIN_ACCOUNT = 'config_encryption_key'
18
20
 
@@ -160,6 +162,17 @@ module Mysigner
160
162
  @organizations = {}
161
163
 
162
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
+
163
176
  true
164
177
  rescue StandardError => e
165
178
  raise ConfigError, "Failed to clear config: #{e.message}"
@@ -304,16 +317,34 @@ module Mysigner
304
317
  end
305
318
 
306
319
  def get_or_create_encryption_key
307
- # Try to get key from keychain
308
- 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
309
335
  return key if key
310
336
 
311
- # Generate new key and store in keychain
337
+ warn_keychain_unavailable_once
338
+ ensure_config_dir_exists
312
339
  new_key = SecureRandom.bytes(32) # 256-bit key
313
- store_key_in_keychain(new_key)
340
+ write_key_to_file(new_key)
314
341
  new_key
315
342
  end
316
343
 
344
+ def macos_keychain?
345
+ RbConfig::CONFIG['host_os'] =~ /darwin/i
346
+ end
347
+
317
348
  def get_key_from_keychain
318
349
  # Use macOS security command to get key from keychain
319
350
  cmd = "security find-generic-password -s '#{KEYCHAIN_SERVICE}' -a '#{KEYCHAIN_ACCOUNT}' -w 2>/dev/null"
@@ -342,6 +373,39 @@ module Mysigner
342
373
  rescue StandardError => e
343
374
  raise ConfigError, "Failed to store encryption key in keychain: #{e.message}"
344
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
345
409
  end
346
410
 
347
411
  class ConfigError < StandardError; end
@@ -108,9 +108,14 @@ module Mysigner
108
108
  '-allowProvisioningUpdates' # Allow Xcode to update profiles if needed
109
109
  ].join(' ')
110
110
 
111
- # Run command and capture output
111
+ # Run command and capture output. xcodebuild output can contain
112
+ # non-ASCII bytes (smart quotes in Apple error messages, emoji in
113
+ # file paths) — force UTF-8 + scrub so `.strip` / `.include?` don't
114
+ # raise Encoding::CompatibilityError under the default US-ASCII
115
+ # locale (e.g. on CI runners without LANG set).
112
116
  IO.popen(cmd, err: %i[child out]) do |io|
113
117
  io.each_line do |line|
118
+ line = line.force_encoding('UTF-8').scrub
114
119
  next if line.strip.empty?
115
120
 
116
121
  # Show errors and warnings
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+
6
+ module Mysigner
7
+ module Signing
8
+ class GradleSigningInjector
9
+ # Local variable is `aliasName` (not `keyAlias`) to avoid a Groovy
10
+ # `with { }` scoping ambiguity: `keyAlias = keyAlias` would resolve the
11
+ # RHS against the property being set (null at that point), silently
12
+ # signing with the wrong alias.
13
+ INIT_SCRIPT = <<~GROOVY
14
+ allprojects {
15
+ afterEvaluate { project ->
16
+ if (!project.hasProperty('android')) return
17
+ def storePw = System.getenv('MYSIGNER_STORE_PASSWORD')
18
+ def keyPw = System.getenv('MYSIGNER_KEY_PASSWORD')
19
+ def aliasName = System.getenv('MYSIGNER_KEY_ALIAS')
20
+ def ksPath = System.getenv('MYSIGNER_STORE_FILE')
21
+ if (!storePw || !ksPath) return
22
+
23
+ def existing = project.android.signingConfigs.findByName('release')
24
+ def alreadyConfigured = existing != null && existing.storeFile != null
25
+ if (alreadyConfigured) {
26
+ println "MySigner: release signingConfig already set; skipping override."
27
+ return
28
+ }
29
+ project.android.signingConfigs.maybeCreate('release').with {
30
+ storeFile = file(ksPath)
31
+ storePassword = storePw
32
+ keyAlias = aliasName
33
+ keyPassword = keyPw
34
+ }
35
+ project.android.buildTypes.findByName('release')?.signingConfig =
36
+ project.android.signingConfigs.getByName('release')
37
+ }
38
+ }
39
+ GROOVY
40
+
41
+ def initialize
42
+ @tmpdir = nil
43
+ end
44
+
45
+ def write_init_script!
46
+ @tmpdir = Dir.mktmpdir('mysigner-signing-')
47
+ path = File.join(@tmpdir, 'init.gradle')
48
+ File.write(path, INIT_SCRIPT)
49
+ path
50
+ end
51
+
52
+ def env_vars(keystore_path:, store_password:, key_password:, key_alias:)
53
+ {
54
+ 'MYSIGNER_STORE_FILE' => keystore_path,
55
+ 'MYSIGNER_STORE_PASSWORD' => store_password,
56
+ 'MYSIGNER_KEY_PASSWORD' => key_password,
57
+ 'MYSIGNER_KEY_ALIAS' => key_alias
58
+ }
59
+ end
60
+
61
+ def cleanup!
62
+ FileUtils.rm_rf(@tmpdir) if @tmpdir && Dir.exist?(@tmpdir)
63
+ @tmpdir = nil
64
+ end
65
+ end
66
+ end
67
+ end
@@ -3,6 +3,7 @@
3
3
  require 'English'
4
4
  require 'fileutils'
5
5
  require 'base64'
6
+ require 'open3'
6
7
 
7
8
  module Mysigner
8
9
  module Signing
@@ -21,11 +22,13 @@ module Mysigner
21
22
 
22
23
  # List all keystores from API
23
24
  # @param android_app_id [Integer, nil] Filter by app ID
24
- # @param include_secrets [Boolean] Include passwords in response (only for build operations)
25
- def list(android_app_id: nil, include_secrets: false)
25
+ # @param include_secrets [Boolean] DEPRECATED and silently ignored. Kept
26
+ # for signature-compat during the 10.0 transition; will be removed in
27
+ # the next release. Passwords are now fetched via #fetch_secrets.
28
+ def list(android_app_id: nil, include_secrets: nil)
29
+ _ = include_secrets # intentionally unused — see note above
26
30
  params = {}
27
31
  params[:android_app_id] = android_app_id if android_app_id
28
- params[:include_secrets] = true if include_secrets
29
32
 
30
33
  response = @client.get(
31
34
  "/api/v1/organizations/#{@organization_id}/android_keystores",
@@ -35,12 +38,24 @@ module Mysigner
35
38
  end
36
39
 
37
40
  # Get active keystore for an app (or any active keystore if no app specified)
38
- # @param include_secrets [Boolean] Include passwords in response (only for build operations)
39
- def active_keystore(android_app_id: nil, include_secrets: false)
40
- keystores = list(android_app_id: android_app_id, include_secrets: include_secrets)
41
+ # @param include_secrets [Boolean] DEPRECATED see #list.
42
+ def active_keystore(android_app_id: nil, include_secrets: nil)
43
+ _ = include_secrets
44
+ keystores = list(android_app_id: android_app_id)
41
45
  keystores.find { |k| k['active'] }
42
46
  end
43
47
 
48
+ # Phase 0: narrow audit-logged endpoint that returns the keystore
49
+ # password + key password + key alias for a single keystore. Replaces
50
+ # the insecure ?include_secrets=true list flag.
51
+ # Returns a hash: { 'keystore_password' =>, 'key_password' =>, 'key_alias' => }
52
+ def fetch_secrets(keystore_id)
53
+ response = @client.post(
54
+ "/api/v1/organizations/#{@organization_id}/android_keystores/#{keystore_id}/secrets"
55
+ )
56
+ response[:data] || {}
57
+ end
58
+
44
59
  # Download a keystore from API and save locally
45
60
  # Returns: { path: String, password: String, alias: String, key_password: String }
46
61
  def download(keystore_id)
@@ -80,28 +95,36 @@ module Mysigner
80
95
  }
81
96
  end
82
97
 
83
- # Get or download keystore (uses cached version if available)
98
+ # Get or download keystore (uses cached version if available and fresh).
99
+ # Phase 0: cache has a TTL (default 24h, override via
100
+ # MYSIGNER_KEYSTORE_CACHE_HOURS). Stale files are deleted + re-downloaded.
84
101
  def get_or_download(keystore_id)
85
102
  keystores = list
86
103
  keystore = keystores.find { |k| k['id'].to_s == keystore_id.to_s }
87
104
 
88
105
  raise KeystoreNotFoundError, "Keystore with ID #{keystore_id} not found" unless keystore
89
106
 
90
- # Check if already cached locally
91
107
  filename = "#{keystore['name'].gsub(/[^a-zA-Z0-9_.-]/, '_')}.jks"
92
108
  local_path = File.join(KEYSTORES_DIR, filename)
109
+ max_age_hours = (ENV['MYSIGNER_KEYSTORE_CACHE_HOURS'] || 24).to_i
93
110
 
94
111
  if File.exist?(local_path)
95
- return {
96
- path: local_path,
97
- name: keystore['name'],
98
- key_alias: keystore['key_alias'],
99
- id: keystore['id'],
100
- cached: true
101
- }
112
+ age_seconds = Time.now - File.mtime(local_path)
113
+ if age_seconds < (max_age_hours * 3600)
114
+ return {
115
+ path: local_path,
116
+ name: keystore['name'],
117
+ key_alias: keystore['key_alias'],
118
+ id: keystore['id'],
119
+ cached: true
120
+ }
121
+ else
122
+ # Stale cache — delete and re-download below
123
+ File.delete(local_path)
124
+ end
102
125
  end
103
126
 
104
- # Download if not cached
127
+ # Download if not cached (or stale)
105
128
  result = download(keystore_id)
106
129
  result[:cached] = false
107
130
  result
@@ -183,13 +206,21 @@ module Mysigner
183
206
  end
184
207
  end
185
208
 
186
- # Get keystore info using keytool
209
+ # Get keystore info using keytool.
210
+ # Phase 0: passes the password via a temp env var consumed by
211
+ # `-storepass:env` so it's never in argv/ps output.
187
212
  def keystore_info(keystore_path, password)
188
213
  return nil unless File.exist?(keystore_path)
189
214
  return nil unless system('which keytool > /dev/null 2>&1')
190
215
 
191
- output = `keytool -list -v -keystore #{shell_escape(keystore_path)} -storepass #{shell_escape(password)} 2>&1`
192
- return nil unless $CHILD_STATUS.success?
216
+ env = { 'MYSIGNER_KS_PW' => password.to_s }
217
+ output, status = Open3.capture2e(
218
+ env,
219
+ 'keytool', '-list', '-v',
220
+ '-keystore', keystore_path,
221
+ '-storepass:env', 'MYSIGNER_KS_PW'
222
+ )
223
+ return nil unless status.success?
193
224
 
194
225
  # Parse output
195
226
  info = {}
@@ -223,12 +254,6 @@ module Mysigner
223
254
  f.adapter Faraday.default_adapter
224
255
  end
225
256
  end
226
-
227
- def shell_escape(str)
228
- return "''" if str.nil? || str.empty?
229
-
230
- "'#{str.gsub("'", "'\\''")}'"
231
- end
232
257
  end
233
258
  end
234
259
  end
@@ -13,6 +13,9 @@ module Mysigner
13
13
  def initialize(client:, organization_id:, opts: {})
14
14
  @client = client
15
15
  @organization_id = organization_id
16
+ # Proper boolean coercion — `!x.nil?` evaluates to `true` for `false`,
17
+ # which previously caused `wait: false` / `no_submit: false` to be
18
+ # treated as `true`. Use `!!` to get the actual boolean semantics.
16
19
  @wait_enabled = opts.key?(:wait) ? !opts[:wait].nil? : true
17
20
 
18
21
  poll = opts[:poll_interval] || opts[:poll_seconds]
@@ -24,6 +27,13 @@ module Mysigner
24
27
  @timeout = timeout&.positive? ? timeout : DEFAULT_WAIT_TIMEOUT
25
28
 
26
29
  @no_submit = !opts[:no_submit].nil?
30
+
31
+ # Whether to submit by default when NEITHER cli_defaults nor CLI
32
+ # overrides specify `auto_submit`. `ship`/`submit` pass true (it's
33
+ # the user's explicit intent). Dashboard `cli_defaults.auto_submit`
34
+ # still wins if set — we only fall back to this when silent.
35
+ @default_submit = opts.key?(:default_submit) ? !opts[:default_submit].nil? : false
36
+
27
37
  @now = opts[:now]
28
38
  end
29
39
 
@@ -317,6 +327,11 @@ module Mysigner
317
327
  def should_submit_with_reason(metadata, overrides)
318
328
  return [false, nil, '--no-auto-submit flag'] if @no_submit
319
329
 
330
+ # Precedence: explicit CLI flag > dashboard cli_defaults > command default.
331
+ # The command default is set by `ship appstore` / `submit` (true),
332
+ # preserving backward-compat when the user runs those commands without
333
+ # configuring anything. Dashboard `auto_submit: false` now correctly
334
+ # suppresses submission (previously hard-overridden at the call site).
320
335
  if overrides.key?('auto_submit')
321
336
  return overrides['auto_submit'] ? [true, 'CLI override',
322
337
  nil] : [false, nil, 'CLI override disabled auto_submit']
@@ -327,6 +342,8 @@ module Mysigner
327
342
  nil] : [false, nil, 'Dashboard auto_submit disabled']
328
343
  end
329
344
 
345
+ return [true, 'command default', nil] if @default_submit
346
+
330
347
  [false, nil, 'No auto_submit configuration']
331
348
  end
332
349
 
@@ -353,14 +370,42 @@ module Mysigner
353
370
  "Cannot submit to Apple Store: missing required fields: #{missing_fields.join(', ')}. Please configure these in your My Signer dashboard or provide via --whats-new flag."
354
371
  end
355
372
 
373
+ # Primary-locale fields (backward-compatible path): backend will
374
+ # upsert a single localization for the app's primary locale when
375
+ # these top-level keys are present.
356
376
  payload = {
357
377
  whats_new: merged['whats_new'],
358
378
  keywords: merged['keywords'],
359
379
  marketing_url: merged['marketing_url'],
360
380
  promotional_text: merged['promotional_text'],
361
381
  support_url: merged['support_url'],
382
+ description: merged['description'],
362
383
  locale: merged['locale']
363
- }.compact # Remove nil values
384
+ }.compact
385
+
386
+ # Multi-locale path: if cli_defaults includes a `localizations` array,
387
+ # forward it so the backend can PATCH/POST each locale on ASC
388
+ # (appStoreVersionLocalizations) instead of only the primary.
389
+ if merged['localizations'].is_a?(Array) && merged['localizations'].any?
390
+ payload[:localizations] = merged['localizations'].map do |loc|
391
+ next nil unless loc.is_a?(Hash)
392
+
393
+ {
394
+ locale: loc['locale'] || loc[:locale],
395
+ whats_new: loc['whats_new'] || loc[:whats_new],
396
+ keywords: loc['keywords'] || loc[:keywords],
397
+ marketing_url: loc['marketing_url'] || loc[:marketing_url],
398
+ promotional_text: loc['promotional_text'] || loc[:promotional_text],
399
+ support_url: loc['support_url'] || loc[:support_url],
400
+ description: loc['description'] || loc[:description]
401
+ }.compact
402
+ end.compact
403
+ end
404
+
405
+ # Phased release: backend flips @version.phased_release_pending and
406
+ # enqueues PhasedReleaseActivationJob, which POSTs
407
+ # appStoreVersionPhasedReleases after Apple approves the submission.
408
+ payload[:phased_release] = true if merged['phased_release']
364
409
 
365
410
  @client.post(
366
411
  "/api/v1/organizations/#{@organization_id}/app_store_versions/#{version_id}/submit",
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'faraday'
5
+ require 'uri'
6
+
7
+ module Mysigner
8
+ module Upload
9
+ class AscRestUploader
10
+ # Raised when Apple rejects the /v1/buildUploads POST with a 409
11
+ # (duplicate CFBundleVersion). Callers should translate this into a
12
+ # "bump your build number" hint rather than a generic "Unexpected error".
13
+ class BuildVersionConflictError < StandardError; end
14
+
15
+ TERMINAL_APPLE_STATES = %w[COMPLETE FAILED INVALIDATED].freeze
16
+ POLL_INTERVAL = 10
17
+ POLL_TIMEOUT = 600
18
+ CHUNK_RETRIES = 2
19
+
20
+ def initialize(client:, organization_id:, ipa_path:, apple_app_id:,
21
+ cf_bundle_version:, cf_bundle_short_version_string:,
22
+ platform: 'IOS', poll_interval: POLL_INTERVAL, poll_timeout: POLL_TIMEOUT)
23
+ @client = client
24
+ @org_id = organization_id
25
+ @ipa_path = ipa_path
26
+ @apple_app_id = apple_app_id
27
+ @cf_bundle_version = cf_bundle_version
28
+ @cf_bundle_short_version_string = cf_bundle_short_version_string
29
+ @platform = platform
30
+ @poll_interval = poll_interval
31
+ @poll_timeout = poll_timeout
32
+ end
33
+
34
+ def call
35
+ begin
36
+ resp = @client.post(
37
+ "/api/v1/organizations/#{@org_id}/builds/asc_upload",
38
+ body: {
39
+ apple_app_id: @apple_app_id,
40
+ cf_bundle_version: @cf_bundle_version,
41
+ cf_bundle_short_version_string: @cf_bundle_short_version_string,
42
+ platform: @platform,
43
+ file_name: File.basename(@ipa_path),
44
+ file_size: File.size(@ipa_path)
45
+ }
46
+ )
47
+ rescue StandardError => e
48
+ # Apple returns 409 from /v1/buildUploads when a build with the
49
+ # same CFBundleVersion already exists for this app. Surface a
50
+ # useful message instead of letting the caller print the raw
51
+ # "ASC /v1/buildUploads returned 409" string.
52
+ if e.message =~ /\b(409|buildUploads returned 409|duplicate)/i
53
+ raise BuildVersionConflictError,
54
+ "Apple refused the upload: build #{@cf_bundle_version} already exists for this app (CFBundleVersion must be unique). " \
55
+ 'Bump CFBundleVersion in your Xcode project and re-archive, then retry.'
56
+ end
57
+ raise
58
+ end
59
+ data = resp[:data]
60
+ build_upload_id = data['build_upload_id']
61
+ ops = data['upload_operations']
62
+
63
+ File.open(@ipa_path, 'rb') do |f|
64
+ ops.each { |op| put_chunk_with_retry(f, op) }
65
+ end
66
+
67
+ md5 = Digest::MD5.file(@ipa_path).hexdigest
68
+ sha = Digest::SHA256.file(@ipa_path).hexdigest
69
+
70
+ @client.patch(
71
+ "/api/v1/organizations/#{@org_id}/builds/asc_upload/#{build_upload_id}",
72
+ body: { uploaded: true, source_file_checksums: { md5: md5, sha256: sha } }
73
+ )
74
+
75
+ final = poll_until_terminal(build_upload_id)
76
+ { build_upload_id: build_upload_id, final_state: final }
77
+ end
78
+
79
+ private
80
+
81
+ def put_chunk_with_retry(file, operation)
82
+ # Defense-in-depth: Apple's signed URLs are always https. If the
83
+ # server ever returns an http:// URL, refuse to PUT the chunk — that
84
+ # would leak the .ipa bytes (and auth headers) in clear text.
85
+ scheme = URI.parse(operation['url'].to_s).scheme
86
+ raise "refusing non-https upload URL (scheme=#{scheme.inspect})" unless scheme == 'https'
87
+
88
+ file.seek(operation['offset'])
89
+ bytes = file.read(operation['length'])
90
+ attempts = 0
91
+ begin
92
+ conn = Faraday.new { |f| f.adapter Faraday.default_adapter }
93
+ resp = conn.public_send(operation['method'].downcase) do |req|
94
+ req.url operation['url']
95
+ (operation['requestHeaders'] || []).each { |h| req.headers[h['name']] = h['value'] }
96
+ req.body = bytes
97
+ end
98
+ raise "chunk PUT failed #{resp.status}" unless resp.status.between?(200, 299)
99
+ rescue StandardError
100
+ attempts += 1
101
+ retry if attempts <= CHUNK_RETRIES
102
+ raise
103
+ end
104
+ end
105
+
106
+ def poll_until_terminal(build_upload_id)
107
+ deadline = Time.now + @poll_timeout
108
+ loop do
109
+ resp = @client.get("/api/v1/organizations/#{@org_id}/builds/asc_upload/#{build_upload_id}")
110
+ state = resp[:data]['apple_state']
111
+ return state if TERMINAL_APPLE_STATES.include?(state)
112
+ return 'TIMEOUT' if Time.now > deadline
113
+
114
+ sleep @poll_interval
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end