mysigner 0.1.7 → 0.2.0

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.
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'open3'
7
+ require 'openssl'
8
+ require 'rbconfig'
9
+
10
+ module Mysigner
11
+ # Local-only credential store for the CLI's local-only mode
12
+ # (Epic mysigner-22). Credentials never leave the user's machine.
13
+ #
14
+ # On macOS, secrets live in the system Keychain under a dedicated service
15
+ # (separate from Config's encryption-key service). On other platforms, the
16
+ # secret is AES-256-GCM-encrypted under the same per-machine key Config
17
+ # already manages, and stored as a 0600 file in ~/.mysigner/credentials/.
18
+ #
19
+ # `list(kind:)` reads a small index file. The index is a convenience for
20
+ # enumeration only; the Keychain entries (or the per-credential encrypted
21
+ # files) are the authoritative source. If the index drifts, callers should
22
+ # treat fetch as the source of truth.
23
+ module LocalCredentials
24
+ KINDS = %i[asc google_play apple_ads android_keystore].freeze
25
+
26
+ KEYCHAIN_SERVICE = 'com.mysigner.cli.credentials'
27
+ CREDENTIALS_DIR = File.expand_path('~/.mysigner/credentials').freeze
28
+ INDEX_DIR = File.join(CREDENTIALS_DIR, '.index').freeze
29
+
30
+ class LocalCredentialsError < StandardError; end
31
+
32
+ class << self
33
+ def store(kind:, id:, secret:)
34
+ validate!(kind: kind, id: id, secret: secret)
35
+
36
+ if macos?
37
+ store_in_keychain(kind, id, secret)
38
+ else
39
+ store_in_file(kind, id, secret)
40
+ end
41
+
42
+ update_index(kind, id, :add)
43
+ true
44
+ end
45
+
46
+ def fetch(kind:, id:)
47
+ validate_kind_and_id!(kind: kind, id: id)
48
+
49
+ if macos?
50
+ fetch_from_keychain(kind, id)
51
+ else
52
+ fetch_from_file(kind, id)
53
+ end
54
+ end
55
+
56
+ def delete(kind:, id:)
57
+ validate_kind_and_id!(kind: kind, id: id)
58
+
59
+ if macos?
60
+ delete_from_keychain(kind, id)
61
+ else
62
+ delete_from_file(kind, id)
63
+ end
64
+
65
+ update_index(kind, id, :remove)
66
+ true
67
+ end
68
+
69
+ def list(kind:)
70
+ validate_kind!(kind)
71
+
72
+ index_path = index_file_for(kind)
73
+ return [] unless File.exist?(index_path)
74
+
75
+ data = JSON.parse(File.read(index_path))
76
+ return [] unless data.is_a?(Array)
77
+
78
+ data.map(&:to_s)
79
+ rescue JSON::ParserError
80
+ # A corrupt index is non-fatal — the per-credential entries are
81
+ # authoritative. Return empty so the caller can re-store and rebuild.
82
+ []
83
+ end
84
+
85
+ def exists?(kind:, id:)
86
+ !fetch(kind: kind, id: id).nil?
87
+ end
88
+
89
+ private
90
+
91
+ # ---- validation -----------------------------------------------------
92
+
93
+ def validate!(kind:, id:, secret:)
94
+ validate_kind_and_id!(kind: kind, id: id)
95
+ raise ArgumentError, 'secret must be a non-empty String' if secret.nil? || !secret.is_a?(String) || secret.empty?
96
+ end
97
+
98
+ def validate_kind_and_id!(kind:, id:)
99
+ validate_kind!(kind)
100
+ raise ArgumentError, 'id must be a non-empty String' if id.nil? || !id.is_a?(String) || id.strip.empty?
101
+ # The macOS `security` CLI uses getopt-style flag parsing on the
102
+ # `-a <account>` value, so an id like `-D` would be parsed as a
103
+ # different flag (there's no `--` end-of-options delimiter we can
104
+ # use). NUL bytes raise an opaque low-level error from Open3.
105
+ # Reject both up front with a clear message — real-world ids
106
+ # (key_id, client_email, etc.) never start with `-` or contain NUL.
107
+ raise ArgumentError, 'id must not contain NUL bytes' if id.include?("\0")
108
+ raise ArgumentError, 'id must not start with "-" — would be parsed as a security CLI flag' if id.start_with?('-')
109
+ end
110
+
111
+ def validate_kind!(kind)
112
+ return if KINDS.include?(kind)
113
+
114
+ raise ArgumentError, "unknown kind: #{kind.inspect} (allowed: #{KINDS.inspect})"
115
+ end
116
+
117
+ # ---- platform -------------------------------------------------------
118
+
119
+ def macos?
120
+ RbConfig::CONFIG['host_os'] =~ /darwin/i
121
+ end
122
+
123
+ # ---- macOS Keychain backend ----------------------------------------
124
+
125
+ # We shell out via Open3 with an array argv so the shell never expands
126
+ # the user-supplied id (which is part of `account`). This is stricter
127
+ # than Config's backtick idiom — flagged in the implementation report
128
+ # as a deliberate Rule 7 divergence for security.
129
+ def store_in_keychain(kind, id, secret)
130
+ encoded = Base64.strict_encode64(secret)
131
+ account = account_for(kind, id)
132
+
133
+ # Delete existing entry first to keep store idempotent — matches the
134
+ # same idiom in Config#store_key_in_keychain.
135
+ Open3.capture3('security', 'delete-generic-password',
136
+ '-s', KEYCHAIN_SERVICE, '-a', account)
137
+
138
+ _, stderr, status = Open3.capture3('security', 'add-generic-password',
139
+ '-s', KEYCHAIN_SERVICE, '-a', account,
140
+ '-w', encoded)
141
+
142
+ return if status.success?
143
+
144
+ raise LocalCredentialsError, "failed to store credential in keychain: #{stderr.strip}"
145
+ end
146
+
147
+ def fetch_from_keychain(kind, id)
148
+ account = account_for(kind, id)
149
+ stdout, _, status = Open3.capture3('security', 'find-generic-password',
150
+ '-s', KEYCHAIN_SERVICE, '-a', account, '-w')
151
+
152
+ return nil unless status.success?
153
+
154
+ encoded = stdout.strip
155
+ return nil if encoded.empty?
156
+
157
+ Base64.strict_decode64(encoded)
158
+ rescue ArgumentError
159
+ # Base64 decode failed — treat as missing rather than crashing the caller.
160
+ nil
161
+ end
162
+
163
+ def delete_from_keychain(kind, id)
164
+ account = account_for(kind, id)
165
+ # Always returns truthy: `security` returns non-zero when the entry
166
+ # doesn't exist, which is fine for an idempotent delete.
167
+ Open3.capture3('security', 'delete-generic-password',
168
+ '-s', KEYCHAIN_SERVICE, '-a', account)
169
+ true
170
+ end
171
+
172
+ def account_for(kind, id)
173
+ "#{kind}:#{id}"
174
+ end
175
+
176
+ # ---- file fallback backend -----------------------------------------
177
+
178
+ def store_in_file(kind, id, secret)
179
+ ensure_dir(kind_dir(kind))
180
+ File.write(file_for(kind, id), encrypt(secret))
181
+ File.chmod(0o600, file_for(kind, id))
182
+ rescue StandardError => e
183
+ raise LocalCredentialsError, "failed to write credential file: #{e.message}"
184
+ end
185
+
186
+ def fetch_from_file(kind, id)
187
+ path = file_for(kind, id)
188
+ return nil unless File.exist?(path)
189
+
190
+ decrypt(File.read(path))
191
+ rescue StandardError => e
192
+ raise LocalCredentialsError, "failed to read credential file: #{e.message}"
193
+ end
194
+
195
+ def delete_from_file(kind, id)
196
+ FileUtils.rm_f(file_for(kind, id))
197
+ true
198
+ end
199
+
200
+ def kind_dir(kind)
201
+ File.join(CREDENTIALS_DIR, kind.to_s)
202
+ end
203
+
204
+ def file_for(kind, id)
205
+ # Base64-urlsafe so any id (including ones with '/') maps to one filename.
206
+ encoded = Base64.urlsafe_encode64(id, padding: false)
207
+ File.join(kind_dir(kind), encoded)
208
+ end
209
+
210
+ def ensure_dir(path)
211
+ FileUtils.mkdir_p(path)
212
+ File.chmod(0o700, path)
213
+ end
214
+
215
+ # ---- index for list() ----------------------------------------------
216
+
217
+ def index_file_for(kind)
218
+ File.join(INDEX_DIR, "#{kind}.json")
219
+ end
220
+
221
+ def update_index(kind, id, operation)
222
+ ensure_dir(INDEX_DIR)
223
+ path = index_file_for(kind)
224
+
225
+ current = read_index(path)
226
+
227
+ case operation
228
+ when :add then current = (current + [id]).uniq
229
+ when :remove then current -= [id]
230
+ end
231
+
232
+ File.write(path, JSON.generate(current))
233
+ File.chmod(0o600, path)
234
+ end
235
+
236
+ def read_index(path)
237
+ return [] unless File.exist?(path)
238
+
239
+ parsed = JSON.parse(File.read(path))
240
+ parsed.is_a?(Array) ? parsed : []
241
+ rescue JSON::ParserError
242
+ []
243
+ end
244
+
245
+ # ---- AES-256-GCM wrap of the per-machine key from Config -----------
246
+
247
+ def encrypt(plaintext)
248
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
249
+ cipher.encrypt
250
+ cipher.key = machine_key
251
+ iv = cipher.random_iv
252
+
253
+ encrypted = cipher.update(plaintext) + cipher.final
254
+ auth_tag = cipher.auth_tag
255
+
256
+ [Base64.strict_encode64(iv),
257
+ Base64.strict_encode64(auth_tag),
258
+ Base64.strict_encode64(encrypted)].join(':')
259
+ end
260
+
261
+ def decrypt(blob)
262
+ iv_b64, tag_b64, data_b64 = blob.split(':', 3)
263
+ iv = Base64.strict_decode64(iv_b64)
264
+ auth_tag = Base64.strict_decode64(tag_b64)
265
+ encrypted = Base64.strict_decode64(data_b64)
266
+
267
+ decipher = OpenSSL::Cipher.new('aes-256-gcm')
268
+ decipher.decrypt
269
+ decipher.key = machine_key
270
+ decipher.iv = iv
271
+ decipher.auth_tag = auth_tag
272
+
273
+ decipher.update(encrypted) + decipher.final
274
+ end
275
+
276
+ def machine_key
277
+ Mysigner::Config.new.fetch_encryption_key
278
+ end
279
+ end
280
+ end
281
+ end
@@ -20,13 +20,11 @@ module Mysigner
20
20
  ensure_keystores_dir
21
21
  end
22
22
 
23
- # List all keystores from API
23
+ # List all keystores from API. The list payload never contains the
24
+ # keystore_password or key_password (mysigner-49) — callers that need
25
+ # the secrets must use #fetch_secrets.
24
26
  # @param android_app_id [Integer, nil] Filter by app ID
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
27
+ def list(android_app_id: nil)
30
28
  params = {}
31
29
  params[:android_app_id] = android_app_id if android_app_id
32
30
 
@@ -37,10 +35,9 @@ module Mysigner
37
35
  response[:data]['android_keystores'] || []
38
36
  end
39
37
 
40
- # Get active keystore for an app (or any active keystore if no app specified)
41
- # @param include_secrets [Boolean] DEPRECATED see #list.
42
- def active_keystore(android_app_id: nil, include_secrets: nil)
43
- _ = include_secrets
38
+ # Get active keystore for an app (or any active keystore if no app
39
+ # specified). Returns the same secret-free payload shape as #list.
40
+ def active_keystore(android_app_id: nil)
44
41
  keystores = list(android_app_id: android_app_id)
45
42
  keystores.find { |k| k['active'] }
46
43
  end
@@ -5,11 +5,17 @@ module Mysigner
5
5
  class Validator
6
6
  class ValidationError < StandardError; end
7
7
 
8
- def initialize(parser, target_name, configuration = 'Release', team_id: nil)
8
+ # local_only: when true, omit user-facing fix-suggestions that point
9
+ # at the MySigner web dashboard (mysigner-22). A user who passed
10
+ # `--local-only` (or set MYSIGNER_LOCAL_ONLY=1) has explicitly opted
11
+ # out of MySigner; suggesting they log in there to fix an error is
12
+ # nonsense and was a real point of confusion in the Phase 6 ship test.
13
+ def initialize(parser, target_name, configuration = 'Release', team_id: nil, local_only: false)
9
14
  @parser = parser
10
15
  @target_name = target_name
11
16
  @configuration = configuration
12
17
  @team_id_override = team_id
18
+ @local_only = local_only
13
19
  end
14
20
 
15
21
  # Validates signing setup before build
@@ -22,16 +28,21 @@ module Mysigner
22
28
  if team_id.nil? || team_id.empty?
23
29
  result[:errors] << "No development team set for target '#{@target_name}'"
24
30
  result[:errors] << ''
25
- result[:errors] << 'Fix Option 1: Add team to My Signer'
26
- result[:errors] << ' 1. Open https://mysigner.dev'
27
- result[:errors] << ' 2. Go to Settings App Store Connect'
28
- result[:errors] << ' 3. Add your Team ID'
29
- result[:errors] << ' 4. Run: mysigner build (team will auto-fetch)'
30
- result[:errors] << ''
31
- result[:errors] << 'Fix Option 2: Pass team via CLI'
31
+ option_number = 1
32
+ unless @local_only
33
+ result[:errors] << "Fix Option #{option_number}: Add team to My Signer"
34
+ result[:errors] << ' 1. Open https://mysigner.dev'
35
+ result[:errors] << ' 2. Go to Settings App Store Connect'
36
+ result[:errors] << ' 3. Add your Team ID'
37
+ result[:errors] << ' 4. Run: mysigner build (team will auto-fetch)'
38
+ result[:errors] << ''
39
+ option_number += 1
40
+ end
41
+ result[:errors] << "Fix Option #{option_number}: Pass team via CLI"
32
42
  result[:errors] << ' mysigner build --team YOUR_TEAM_ID'
33
43
  result[:errors] << ''
34
- result[:errors] << 'Fix Option 3: Set in Xcode'
44
+ option_number += 1
45
+ result[:errors] << "Fix Option #{option_number}: Set in Xcode"
35
46
  result[:errors] << ' Open Xcode → Select target → Signing & Capabilities → Select a team'
36
47
  result[:errors] << ''
37
48
  result[:errors] << 'Find your team ID at: https://developer.apple.com/account/#!/membership/'