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.
- checksums.yaml +4 -4
- data/.gitignore +25 -0
- data/.rubocop_todo.yml +6 -1
- data/CHANGELOG.md +92 -0
- data/Gemfile.lock +7 -7
- data/README.md +94 -1
- data/exe/mysigner +55 -1
- data/lib/mysigner/auth/asc_jwt_minter.rb +68 -0
- data/lib/mysigner/auth/google_oauth_minter.rb +89 -0
- data/lib/mysigner/cleanup/private_keys_purger.rb +0 -1
- data/lib/mysigner/cli/auth_commands.rb +355 -5
- data/lib/mysigner/cli/build_commands.rb +540 -267
- data/lib/mysigner/cli/concerns/helpers.rb +135 -0
- data/lib/mysigner/cli.rb +3 -2
- data/lib/mysigner/config.rb +40 -1
- data/lib/mysigner/credential_resolver.rb +1099 -0
- data/lib/mysigner/local_credentials.rb +281 -0
- data/lib/mysigner/signing/keystore_manager.rb +7 -10
- data/lib/mysigner/signing/validator.rb +20 -9
- data/lib/mysigner/upload/asc_rest_uploader.rb +252 -35
- data/lib/mysigner/upload/asc_submitter.rb +432 -0
- data/lib/mysigner/upload/play_store_uploader.rb +95 -3
- data/lib/mysigner/version.rb +1 -1
- data/lib/mysigner.rb +1 -0
- metadata +6 -5
- data/certificate_.cer +0 -0
- data/iOS_App_Store_Profile.mobileprovision +0 -1
- data/iOS_Distribution_Certificate.cer +0 -1
- data/profile_.mobileprovision +0 -0
|
@@ -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
|
-
|
|
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
|
|
41
|
-
#
|
|
42
|
-
def active_keystore(android_app_id: 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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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/'
|