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,1099 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
|
|
7
|
+
module Mysigner
|
|
8
|
+
# mysigner-22 Phase 5 — credential auto-discovery cascade for `--local-only`
|
|
9
|
+
# mode. Replaces the original Keychain-only lookup with a fastlane-style
|
|
10
|
+
# cascade: explicit per-command flags → env vars users already set (Apple's
|
|
11
|
+
# APP_STORE_CONNECT_API_KEY_* and Google's GOOGLE_APPLICATION_CREDENTIALS) →
|
|
12
|
+
# Keychain (`onboard --local-only` store) → standard on-disk locations →
|
|
13
|
+
# interactive prompt (TTY only — never in CI).
|
|
14
|
+
#
|
|
15
|
+
# The resolver is the ONLY place that knows about the cascade. Uploaders
|
|
16
|
+
# receive the resolved Struct and don't touch ENV / disk / prompts directly,
|
|
17
|
+
# which keeps the wiring testable (Rule 9: tests verify the cascade, not the
|
|
18
|
+
# uploader plumbing).
|
|
19
|
+
#
|
|
20
|
+
# Backward-compat contract: vault mode (no `--local-only`) never calls this
|
|
21
|
+
# module. The `LocalCredentials` API is unchanged — it just becomes one
|
|
22
|
+
# source in the cascade rather than the only one.
|
|
23
|
+
module CredentialResolver
|
|
24
|
+
# Both Structs include `source` so the caller can log which leg of the
|
|
25
|
+
# cascade won. The CLI prints it before each ship so non-interactive runs
|
|
26
|
+
# (CI) have an audit trail of where the credential came from.
|
|
27
|
+
AscCreds = Struct.new(:key_id, :issuer_id, :p8_pem, :source)
|
|
28
|
+
PlayCreds = Struct.new(:sa_json, :client_email, :source)
|
|
29
|
+
# mysigner-22 Phase 7 — Android signing keystore credentials.
|
|
30
|
+
# `keystore_path` is always an on-disk path (the Gradle signing pipeline
|
|
31
|
+
# expects a file, not bytes). `tmpfile` is held so the Tempfile object
|
|
32
|
+
# isn't GC'd before the process exits — when the resolver materializes a
|
|
33
|
+
# Keychain-stored .jks blob to disk, we keep the Tempfile reference here
|
|
34
|
+
# so the file survives until the CLI process ends.
|
|
35
|
+
AndroidKeystoreCreds = Struct.new(
|
|
36
|
+
:keystore_path, :keystore_password, :key_alias, :key_password, :source, :tmpfile
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Raised when every cascade step fails AND we cannot prompt (non-TTY).
|
|
40
|
+
# The message lists every source tried plus the exact knob (flag / env /
|
|
41
|
+
# onboard command / disk location) the user can set to fix it.
|
|
42
|
+
class CredentialNotFoundError < StandardError; end
|
|
43
|
+
|
|
44
|
+
# Raised when the cascade finds multiple candidates at a tier that can't
|
|
45
|
+
# auto-disambiguate (e.g. several Keychain entries, several .p8 files on
|
|
46
|
+
# disk). Distinct from "not found" because the fix is different — pick one
|
|
47
|
+
# via a flag/env, don't go re-onboard.
|
|
48
|
+
class AmbiguousCredentialsError < StandardError; end
|
|
49
|
+
|
|
50
|
+
# Apple's officially-blessed on-disk location for ASC private keys,
|
|
51
|
+
# documented in WWDC sessions and used by altool / xcrun / fastlane.
|
|
52
|
+
APPLE_PRIVATE_KEYS_DIR = File.expand_path('~/.appstoreconnect/private_keys').freeze
|
|
53
|
+
|
|
54
|
+
# Common file names users put service-account JSON under at a project root.
|
|
55
|
+
PLAY_PROJECT_FILE_NAMES = %w[
|
|
56
|
+
play-credentials.json
|
|
57
|
+
service-account.json
|
|
58
|
+
play-service-account.json
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
# Walk up at most this many parent directories looking for project-sniff
|
|
62
|
+
# files. Three levels covers the common monorepo-with-app-subdir layout
|
|
63
|
+
# without surprising the user by reaching into unrelated trees.
|
|
64
|
+
PROJECT_SNIFF_MAX_DEPTH = 3
|
|
65
|
+
|
|
66
|
+
# Project-sniff filenames for Android signing config. `android/key.properties`
|
|
67
|
+
# is the Flutter convention (the `flutter create` template writes it);
|
|
68
|
+
# `android/keystore.properties` and a root-level `key.properties` are seen
|
|
69
|
+
# in a few Gradle-only setups (the docs use both names interchangeably).
|
|
70
|
+
# First match wins.
|
|
71
|
+
ANDROID_KEY_PROPERTIES_FILES = [
|
|
72
|
+
'android/key.properties',
|
|
73
|
+
'android/keystore.properties',
|
|
74
|
+
'key.properties'
|
|
75
|
+
].freeze
|
|
76
|
+
|
|
77
|
+
# Human-readable labels for the four cascade pieces. Used by the
|
|
78
|
+
# not-found error message so each `missing` symbol prints something
|
|
79
|
+
# the user can map back to a flag/env/sniff-key.
|
|
80
|
+
ANDROID_MISSING_LABELS = {
|
|
81
|
+
path: 'keystore path',
|
|
82
|
+
keystore_password: 'keystore password',
|
|
83
|
+
key_alias: 'key alias',
|
|
84
|
+
key_password: 'key password'
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
# @param options [Hash] Thor options hash with --asc-key-path/id/issuer-id
|
|
89
|
+
# @param env [Hash] ENV substitute for testability
|
|
90
|
+
# @param stdin [IO] $stdin substitute (we check #tty? for prompt gating)
|
|
91
|
+
# @param stderr [IO] $stderr substitute (for the prompt itself)
|
|
92
|
+
# @return [AscCreds]
|
|
93
|
+
# @raise [CredentialNotFoundError] when nothing usable was found and we can't prompt
|
|
94
|
+
def resolve_asc(options: {}, env: ENV, stdin: $stdin, stderr: $stderr)
|
|
95
|
+
tried = []
|
|
96
|
+
|
|
97
|
+
# Tier 1: per-command CLI flags (highest precedence). If all three are
|
|
98
|
+
# present we short-circuit; partial flags layer in below.
|
|
99
|
+
flag_path = string_option(options, :asc_key_path)
|
|
100
|
+
flag_key_id = string_option(options, :asc_key_id)
|
|
101
|
+
flag_issuer = string_option(options, :asc_issuer_id)
|
|
102
|
+
if flag_path && flag_key_id && flag_issuer
|
|
103
|
+
pem = read_pem!(flag_path, label: '--asc-key-path')
|
|
104
|
+
tried << "flag: --asc-key-path=#{flag_path}"
|
|
105
|
+
return AscCreds.new(key_id: flag_key_id, issuer_id: flag_issuer, p8_pem: pem, source: :flag)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Tier 2: env vars (fastlane convention).
|
|
109
|
+
env_path = string_env(env, 'APP_STORE_CONNECT_API_KEY_PATH')
|
|
110
|
+
env_key_id = string_env(env, 'APP_STORE_CONNECT_API_KEY_ID')
|
|
111
|
+
env_issuer = string_env(env, 'APP_STORE_CONNECT_API_KEY_ISSUER_ID')
|
|
112
|
+
|
|
113
|
+
# Tier 3: Keychain — list yields zero / one / many; "many without
|
|
114
|
+
# disambiguator" is an explicit ambiguous-error rather than silently
|
|
115
|
+
# picking the first. WHY: the old uploader took .first quietly, which
|
|
116
|
+
# was fine when only `onboard --local-only` could write entries but is
|
|
117
|
+
# dangerous now that users may layer flags/env on top.
|
|
118
|
+
keychain_ids = safe_list_keychain(:asc)
|
|
119
|
+
keychain_id = pick_keychain_id(keychain_ids, hint: flag_key_id || env_key_id, label: 'ASC')
|
|
120
|
+
tried << "keychain: #{keychain_ids.length} entr#{keychain_ids.length == 1 ? 'y' : 'ies'}"
|
|
121
|
+
|
|
122
|
+
# Tier 4: disk scan. Skip entirely when a higher tier (flag or env)
|
|
123
|
+
# already supplies a .p8 path — disk can't add anything we'd prefer.
|
|
124
|
+
# WHY: scan_apple_private_keys_dir raises AmbiguousCredentialsError on
|
|
125
|
+
# multiple .p8 files, and that error is misleading when the user has
|
|
126
|
+
# already pointed at one via --asc-key-path / APP_STORE_CONNECT_API_KEY_PATH.
|
|
127
|
+
# "Higher tier wins" means the lower tier shouldn't even probe.
|
|
128
|
+
disk_path, disk_key_id =
|
|
129
|
+
if flag_path || env_path
|
|
130
|
+
[nil, nil]
|
|
131
|
+
else
|
|
132
|
+
scan_apple_private_keys_dir(tried)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Stitch together the highest-priority *partial* tier first, then fill
|
|
136
|
+
# the missing fields from lower tiers. This lets "disk found the .p8
|
|
137
|
+
# but no issuer_id env" continue down to env then to prompt without
|
|
138
|
+
# restarting the cascade. Pieces are passed as one hash to keep the
|
|
139
|
+
# helper under the parameter-list cop limit.
|
|
140
|
+
path, key_id, issuer_id, source = assemble_asc_pieces(
|
|
141
|
+
flag_path: flag_path, flag_key_id: flag_key_id, flag_issuer: flag_issuer,
|
|
142
|
+
env_path: env_path, env_key_id: env_key_id, env_issuer: env_issuer,
|
|
143
|
+
keychain_id: keychain_id, disk_path: disk_path, disk_key_id: disk_key_id
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Keychain shortcut: when keychain holds the (key_id, issuer_id, pem)
|
|
147
|
+
# triple we don't need disk/env for anything.
|
|
148
|
+
if source == :keychain
|
|
149
|
+
envelope = fetch_keychain_envelope(:asc, key_id, label: 'ASC')
|
|
150
|
+
return AscCreds.new(
|
|
151
|
+
key_id: key_id,
|
|
152
|
+
issuer_id: envelope.fetch('issuer_id'),
|
|
153
|
+
p8_pem: envelope.fetch('p8_pem'),
|
|
154
|
+
source: :keychain
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# All other tiers need a PEM read from disk.
|
|
159
|
+
pem = path ? read_pem!(path, label: source_label(source)) : nil
|
|
160
|
+
|
|
161
|
+
# Final fallback: prompt only when STDIN is a TTY. CI must fail loud.
|
|
162
|
+
if path.nil? || key_id.nil? || issuer_id.nil?
|
|
163
|
+
unless stdin.respond_to?(:tty?) && stdin.tty?
|
|
164
|
+
raise CredentialNotFoundError, asc_not_found_message(
|
|
165
|
+
tried: tried,
|
|
166
|
+
missing: { path: path.nil?, key_id: key_id.nil?, issuer_id: issuer_id.nil? }
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if path.nil?
|
|
171
|
+
path = prompt(stderr, stdin, 'Path to your App Store Connect .p8 private key:')
|
|
172
|
+
pem = read_pem!(File.expand_path(path), label: 'prompt')
|
|
173
|
+
end
|
|
174
|
+
key_id ||= derive_key_id_from_filename(path) || prompt(stderr, stdin, 'App Store Connect Key ID:')
|
|
175
|
+
issuer_id ||= prompt(stderr, stdin, 'App Store Connect Issuer ID (UUID):')
|
|
176
|
+
# Only overwrite source to :prompt when the .p8 path itself was
|
|
177
|
+
# prompted (source.nil? means no higher tier supplied it). When the
|
|
178
|
+
# path came from env/flag/keychain/disk and only issuer_id was
|
|
179
|
+
# prompted in, preserve the originating source — the audit log
|
|
180
|
+
# should attribute the credential to where the primary material
|
|
181
|
+
# (the .p8) came from; issuer_id is metadata.
|
|
182
|
+
source ||= :prompt
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
AscCreds.new(key_id: key_id, issuer_id: issuer_id, p8_pem: pem, source: source)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @param options [Hash] Thor options with --play-credentials
|
|
189
|
+
# @param env [Hash]
|
|
190
|
+
# @param stdin [IO]
|
|
191
|
+
# @param stderr [IO]
|
|
192
|
+
# @param cwd [String] starting dir for project-sniff (Dir.pwd in prod)
|
|
193
|
+
# @return [PlayCreds]
|
|
194
|
+
# @raise [CredentialNotFoundError]
|
|
195
|
+
def resolve_play(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd)
|
|
196
|
+
tried = []
|
|
197
|
+
|
|
198
|
+
# Tier 1: flag.
|
|
199
|
+
if (flag_path = string_option(options, :play_credentials))
|
|
200
|
+
tried << "flag: --play-credentials=#{flag_path}"
|
|
201
|
+
raw, email = read_sa_json!(flag_path, label: '--play-credentials')
|
|
202
|
+
return PlayCreds.new(sa_json: raw, client_email: email, source: :flag)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Tier 2: env (Google's documented convention).
|
|
206
|
+
if (env_path = string_env(env, 'GOOGLE_APPLICATION_CREDENTIALS'))
|
|
207
|
+
tried << "env: GOOGLE_APPLICATION_CREDENTIALS=#{env_path}"
|
|
208
|
+
raw, email = read_sa_json!(env_path, label: 'GOOGLE_APPLICATION_CREDENTIALS')
|
|
209
|
+
return PlayCreds.new(sa_json: raw, client_email: email, source: :env)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Tier 3: Keychain.
|
|
213
|
+
keychain_ids = safe_list_keychain(:google_play)
|
|
214
|
+
tried << "keychain: #{keychain_ids.length} entr#{keychain_ids.length == 1 ? 'y' : 'ies'}"
|
|
215
|
+
if keychain_ids.length == 1
|
|
216
|
+
raw = fetch_keychain_raw(:google_play, keychain_ids.first, label: 'Google Play')
|
|
217
|
+
return PlayCreds.new(sa_json: raw, client_email: keychain_ids.first, source: :keychain)
|
|
218
|
+
elsif keychain_ids.length > 1
|
|
219
|
+
raise AmbiguousCredentialsError,
|
|
220
|
+
"Multiple Google Play credentials in Keychain (#{keychain_ids.join(', ')}). " \
|
|
221
|
+
'Pass --play-credentials PATH to disambiguate, or remove the unused ones with ' \
|
|
222
|
+
'`mysigner local-credential delete google_play <client_email>`.'
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Tier 4: project-sniff (walk up to PROJECT_SNIFF_MAX_DEPTH dirs).
|
|
226
|
+
if (sniffed = sniff_project_for_play(cwd, tried))
|
|
227
|
+
raw, email = read_sa_json!(sniffed, label: 'project-sniff')
|
|
228
|
+
return PlayCreds.new(sa_json: raw, client_email: email, source: :disk)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Tier 5: prompt or fail.
|
|
232
|
+
raise CredentialNotFoundError, play_not_found_message(tried: tried) unless stdin.respond_to?(:tty?) && stdin.tty?
|
|
233
|
+
|
|
234
|
+
path = prompt(stderr, stdin, 'Path to your Google Play service-account JSON:')
|
|
235
|
+
raw, email = read_sa_json!(File.expand_path(path), label: 'prompt')
|
|
236
|
+
PlayCreds.new(sa_json: raw, client_email: email, source: :prompt)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# mysigner-22 Phase 7 — Android keystore cascade.
|
|
240
|
+
#
|
|
241
|
+
# Each tier may contribute any subset of the four required pieces
|
|
242
|
+
# (path, keystore_password, key_alias, key_password); the resolver
|
|
243
|
+
# stitches them together highest-priority-first and prompts (TTY) or
|
|
244
|
+
# fails (non-TTY) for whatever is still missing.
|
|
245
|
+
#
|
|
246
|
+
# Priority: flag > env > keychain > project-sniff > prompt.
|
|
247
|
+
#
|
|
248
|
+
# @return [AndroidKeystoreCreds]
|
|
249
|
+
# @raise [CredentialNotFoundError]
|
|
250
|
+
# @raise [AmbiguousCredentialsError]
|
|
251
|
+
def resolve_android_keystore(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd)
|
|
252
|
+
tried = []
|
|
253
|
+
# Layered hash {path:, keystore_password:, key_alias:, key_password:, source:, tmpfile:}.
|
|
254
|
+
# `path_source` mirrors the ASC cascade contract: source attribution
|
|
255
|
+
# follows the tier that supplied the .jks (the primary material), not
|
|
256
|
+
# the tier that filled in a stray password field.
|
|
257
|
+
pieces = {}
|
|
258
|
+
|
|
259
|
+
layer_android_flag_pieces!(pieces, options)
|
|
260
|
+
layer_android_env_pieces!(pieces, env)
|
|
261
|
+
layer_android_keychain_pieces!(pieces, hint: pieces[:key_alias], tried: tried)
|
|
262
|
+
layer_android_sniff_pieces!(pieces, cwd: cwd, tried: tried)
|
|
263
|
+
|
|
264
|
+
# All tiers exhausted — prompt for what's still missing (TTY only).
|
|
265
|
+
missing = android_missing_pieces(pieces)
|
|
266
|
+
if missing.any?
|
|
267
|
+
unless stdin.respond_to?(:tty?) && stdin.tty?
|
|
268
|
+
raise CredentialNotFoundError, android_keystore_not_found_message(tried: tried, missing: missing)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
fill_android_pieces_from_prompt!(pieces, missing, stdin: stdin, stderr: stderr)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
AndroidKeystoreCreds.new(
|
|
275
|
+
keystore_path: pieces[:path],
|
|
276
|
+
keystore_password: pieces[:keystore_password],
|
|
277
|
+
key_alias: pieces[:key_alias],
|
|
278
|
+
key_password: pieces[:key_password] || pieces[:keystore_password],
|
|
279
|
+
source: pieces[:source] || :prompt,
|
|
280
|
+
tmpfile: pieces[:tmpfile]
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# ---- shared helpers ------------------------------------------------
|
|
285
|
+
|
|
286
|
+
private
|
|
287
|
+
|
|
288
|
+
def string_option(options, key)
|
|
289
|
+
return nil if options.nil?
|
|
290
|
+
|
|
291
|
+
# Thor symbolizes; tolerate string keys for direct callers.
|
|
292
|
+
val = options[key] || options[key.to_s]
|
|
293
|
+
return nil if val.nil?
|
|
294
|
+
|
|
295
|
+
s = val.to_s.strip
|
|
296
|
+
s.empty? ? nil : s
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def string_env(env, key)
|
|
300
|
+
val = env[key]
|
|
301
|
+
return nil if val.nil?
|
|
302
|
+
|
|
303
|
+
s = val.to_s.strip
|
|
304
|
+
s.empty? ? nil : s
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def safe_list_keychain(kind)
|
|
308
|
+
# Lazy-require so production scripts that never trigger local-only
|
|
309
|
+
# don't pay the LocalCredentials load cost.
|
|
310
|
+
require 'mysigner/local_credentials'
|
|
311
|
+
Mysigner::LocalCredentials.list(kind: kind)
|
|
312
|
+
rescue StandardError
|
|
313
|
+
# A misconfigured Keychain shouldn't crash the cascade — treat as
|
|
314
|
+
# "no entries here, try the next tier."
|
|
315
|
+
[]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# WHY this isn't `ids.first`: when the user has multiple ASC keys in
|
|
319
|
+
# Keychain (very common once they switch teams) we used to silently pick
|
|
320
|
+
# one. Now: if a hint (flag --asc-key-id or APP_STORE_CONNECT_API_KEY_ID)
|
|
321
|
+
# narrows the choice, we use it; if the hint doesn't match any keychain
|
|
322
|
+
# entry, we treat keychain as "not useful here" and let lower tiers
|
|
323
|
+
# provide the path; if no hint and one entry, that one wins; otherwise
|
|
324
|
+
# we raise AmbiguousCredentialsError.
|
|
325
|
+
def pick_keychain_id(ids, hint:, label:)
|
|
326
|
+
return nil if ids.empty?
|
|
327
|
+
|
|
328
|
+
if hint
|
|
329
|
+
return hint if ids.include?(hint)
|
|
330
|
+
|
|
331
|
+
# Hint set but no keychain match — caller will use disk/env path
|
|
332
|
+
# for this key_id. Don't raise here.
|
|
333
|
+
return nil
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
return ids.first if ids.length == 1
|
|
337
|
+
|
|
338
|
+
raise AmbiguousCredentialsError,
|
|
339
|
+
"Multiple #{label} credentials in Keychain (#{ids.join(', ')}). " \
|
|
340
|
+
'Pass --asc-key-id KEY_ID (or set APP_STORE_CONNECT_API_KEY_ID) to disambiguate.'
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Android-specific keychain picker — same shape as pick_keychain_id but
|
|
344
|
+
# the disambiguation knob is `--key-alias` (the alias *is* the keychain
|
|
345
|
+
# account id for android_keystore), so the error wording differs.
|
|
346
|
+
def pick_android_keychain_id(ids, hint:)
|
|
347
|
+
return nil if ids.empty?
|
|
348
|
+
|
|
349
|
+
if hint
|
|
350
|
+
return hint if ids.include?(hint)
|
|
351
|
+
|
|
352
|
+
return nil
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
return ids.first if ids.length == 1
|
|
356
|
+
|
|
357
|
+
raise AmbiguousCredentialsError,
|
|
358
|
+
"Multiple Android keystore credentials in Keychain (#{ids.join(', ')}). " \
|
|
359
|
+
'Pass --key-alias ALIAS (or set MYSIGNER_KEY_ALIAS) to disambiguate.'
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def fetch_keychain_envelope(kind, id, label:)
|
|
363
|
+
require 'mysigner/local_credentials'
|
|
364
|
+
raw = Mysigner::LocalCredentials.fetch(kind: kind, id: id)
|
|
365
|
+
if raw.nil?
|
|
366
|
+
raise CredentialNotFoundError,
|
|
367
|
+
"Keychain index lists `#{id}` but the secret is missing. " \
|
|
368
|
+
'Re-store with `mysigner onboard --local-only`.'
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
parsed = begin
|
|
372
|
+
JSON.parse(raw)
|
|
373
|
+
rescue JSON::ParserError => e
|
|
374
|
+
raise CredentialNotFoundError,
|
|
375
|
+
"#{label} Keychain entry `#{id}` is not valid JSON (#{e.message}). " \
|
|
376
|
+
'Re-store with `mysigner onboard --local-only`.'
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
unless parsed.is_a?(Hash) && parsed['issuer_id'] && parsed['p8_pem']
|
|
380
|
+
raise CredentialNotFoundError,
|
|
381
|
+
"#{label} Keychain entry `#{id}` is missing required fields (need issuer_id + p8_pem). " \
|
|
382
|
+
'Re-store with `mysigner onboard --local-only`.'
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
parsed
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def fetch_keychain_raw(kind, id, label:)
|
|
389
|
+
require 'mysigner/local_credentials'
|
|
390
|
+
raw = Mysigner::LocalCredentials.fetch(kind: kind, id: id)
|
|
391
|
+
return raw if raw && !raw.empty?
|
|
392
|
+
|
|
393
|
+
raise CredentialNotFoundError,
|
|
394
|
+
"#{label} Keychain index lists `#{id}` but the secret is missing. " \
|
|
395
|
+
'Re-store with `mysigner onboard --local-only`.'
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def scan_apple_private_keys_dir(tried)
|
|
399
|
+
dir = APPLE_PRIVATE_KEYS_DIR
|
|
400
|
+
unless File.directory?(dir)
|
|
401
|
+
tried << "disk: #{dir} (not present)"
|
|
402
|
+
return [nil, nil]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
files = Dir.glob(File.join(dir, 'AuthKey_*.p8'))
|
|
406
|
+
files_word = files.length == 1 ? 'file' : 'files'
|
|
407
|
+
tried << "disk: #{dir} (#{files.length} AuthKey_*.p8 #{files_word})"
|
|
408
|
+
|
|
409
|
+
if files.length == 1
|
|
410
|
+
path = files.first
|
|
411
|
+
[path, derive_key_id_from_filename(path)]
|
|
412
|
+
elsif files.length > 1
|
|
413
|
+
# Multiple files on disk is ambiguous in the SAME way as multi-
|
|
414
|
+
# Keychain. Surface it; let the flag/env-var pick.
|
|
415
|
+
raise AmbiguousCredentialsError,
|
|
416
|
+
"Multiple ASC private keys found in #{dir}: " \
|
|
417
|
+
"#{files.map { |f| File.basename(f) }.join(', ')}. " \
|
|
418
|
+
'Pass --asc-key-path PATH (or set APP_STORE_CONNECT_API_KEY_PATH) to pick one.'
|
|
419
|
+
else
|
|
420
|
+
[nil, nil]
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def derive_key_id_from_filename(path)
|
|
425
|
+
return nil if path.nil?
|
|
426
|
+
|
|
427
|
+
File.basename(path) =~ /\AAuthKey_([A-Z0-9]+)\.p8\z/i ? Regexp.last_match(1) : nil
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Build the "what we have, what we need" triple by overlaying tiers from
|
|
431
|
+
# high → low priority. Returns [path, key_id, issuer_id, winning_source].
|
|
432
|
+
# WHY return :source from here: the cascade can win at the highest tier
|
|
433
|
+
# that contributed the path (which is what we tell the user about).
|
|
434
|
+
# `parts` is a single Hash to keep the parameter list under the cop
|
|
435
|
+
# limit; every key is required so a missing one is a programmer error.
|
|
436
|
+
def assemble_asc_pieces(parts)
|
|
437
|
+
# If keychain has a complete triple, prefer it over disk because the
|
|
438
|
+
# user explicitly onboarded it (intent signal). The keychain branch
|
|
439
|
+
# short-circuits in the caller, so we just signal it here.
|
|
440
|
+
return [nil, parts[:keychain_id], nil, :keychain] if parts[:keychain_id]
|
|
441
|
+
|
|
442
|
+
path = parts[:flag_path] || parts[:env_path] || parts[:disk_path]
|
|
443
|
+
key_id = parts[:flag_key_id] || parts[:env_key_id] || derive_key_id_from_filename(path) || parts[:disk_key_id]
|
|
444
|
+
issuer = parts[:flag_issuer] || parts[:env_issuer]
|
|
445
|
+
source = source_for(path, flag_path: parts[:flag_path], env_path: parts[:env_path], disk_path: parts[:disk_path])
|
|
446
|
+
[path, key_id, issuer, source]
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def source_for(path, flag_path:, env_path:, disk_path:)
|
|
450
|
+
return :flag if path && path == flag_path
|
|
451
|
+
return :env if path && path == env_path
|
|
452
|
+
return :disk if path && path == disk_path
|
|
453
|
+
|
|
454
|
+
nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def source_label(source)
|
|
458
|
+
case source
|
|
459
|
+
when :flag then '--asc-key-path'
|
|
460
|
+
when :env then 'APP_STORE_CONNECT_API_KEY_PATH'
|
|
461
|
+
when :disk then APPLE_PRIVATE_KEYS_DIR
|
|
462
|
+
else 'prompt'
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def read_pem!(path, label:)
|
|
467
|
+
expanded = File.expand_path(path)
|
|
468
|
+
unless File.exist?(expanded)
|
|
469
|
+
raise CredentialNotFoundError,
|
|
470
|
+
"ASC private key file not found at #{expanded} (from #{label}). " \
|
|
471
|
+
'Check the path and try again.'
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
File.read(expanded)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def read_sa_json!(path, label:)
|
|
478
|
+
expanded = File.expand_path(path)
|
|
479
|
+
unless File.exist?(expanded)
|
|
480
|
+
raise CredentialNotFoundError,
|
|
481
|
+
"Google Play service-account JSON not found at #{expanded} (from #{label}). " \
|
|
482
|
+
'Check the path and try again.'
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
raw = File.read(expanded)
|
|
486
|
+
parsed = begin
|
|
487
|
+
JSON.parse(raw)
|
|
488
|
+
rescue JSON::ParserError => e
|
|
489
|
+
raise CredentialNotFoundError,
|
|
490
|
+
"Google Play service-account JSON at #{expanded} is not valid JSON (#{e.message})."
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
unless parsed['type'] == 'service_account' && parsed['client_email'] && parsed['private_key']
|
|
494
|
+
raise CredentialNotFoundError,
|
|
495
|
+
"Google Play service-account JSON at #{expanded} is missing required fields " \
|
|
496
|
+
"(need type='service_account', client_email, private_key)."
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
[raw, parsed['client_email']]
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Walks `cwd` up to PROJECT_SNIFF_MAX_DEPTH parents looking for any of:
|
|
503
|
+
# - eas.json with submit.<profile>.android.serviceAccountKeyPath
|
|
504
|
+
# - PLAY_PROJECT_FILE_NAMES at the root
|
|
505
|
+
# First hit wins (closer-to-cwd dirs first). Records what was tried so
|
|
506
|
+
# the not-found error names the dirs.
|
|
507
|
+
def sniff_project_for_play(cwd, tried)
|
|
508
|
+
return nil if cwd.nil?
|
|
509
|
+
|
|
510
|
+
dir = File.expand_path(cwd)
|
|
511
|
+
PROJECT_SNIFF_MAX_DEPTH.times do
|
|
512
|
+
tried << "disk: project-sniff in #{dir}"
|
|
513
|
+
|
|
514
|
+
eas_path = File.join(dir, 'eas.json')
|
|
515
|
+
if File.exist?(eas_path)
|
|
516
|
+
sa = extract_sa_path_from_eas(eas_path, dir)
|
|
517
|
+
return sa if sa
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
PLAY_PROJECT_FILE_NAMES.each do |name|
|
|
521
|
+
candidate = File.join(dir, name)
|
|
522
|
+
return candidate if File.exist?(candidate)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
parent = File.dirname(dir)
|
|
526
|
+
break if parent == dir # filesystem root
|
|
527
|
+
|
|
528
|
+
dir = parent
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
nil
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# eas.json shape: submit.<profile>.android.serviceAccountKeyPath. Walk
|
|
535
|
+
# the production / preview / default profiles in that order — production
|
|
536
|
+
# is the canonical ship target, preview is the second most common in
|
|
537
|
+
# Expo docs.
|
|
538
|
+
def extract_sa_path_from_eas(eas_path, dir)
|
|
539
|
+
json = JSON.parse(File.read(eas_path))
|
|
540
|
+
submit = json['submit'] || {}
|
|
541
|
+
%w[production preview default].each do |profile|
|
|
542
|
+
path = submit.dig(profile, 'android', 'serviceAccountKeyPath')
|
|
543
|
+
next if path.nil? || path.to_s.strip.empty?
|
|
544
|
+
|
|
545
|
+
# EAS paths can be relative to the eas.json dir.
|
|
546
|
+
resolved = File.expand_path(path, dir)
|
|
547
|
+
return resolved if File.exist?(resolved)
|
|
548
|
+
end
|
|
549
|
+
nil
|
|
550
|
+
rescue JSON::ParserError
|
|
551
|
+
# Malformed eas.json is the user's problem in their own toolchain;
|
|
552
|
+
# we just skip it and let the cascade fall through.
|
|
553
|
+
nil
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def prompt(stderr, stdin, label)
|
|
557
|
+
stderr.print "#{label} " if stderr.respond_to?(:print)
|
|
558
|
+
stdin.gets.to_s.strip.gsub(/\A['"]|['"]\z/, '')
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def asc_not_found_message(tried:, missing:)
|
|
562
|
+
missing_pieces = []
|
|
563
|
+
missing_pieces << 'path to .p8' if missing[:path]
|
|
564
|
+
missing_pieces << 'Key ID' if missing[:key_id]
|
|
565
|
+
missing_pieces << 'Issuer ID' if missing[:issuer_id]
|
|
566
|
+
|
|
567
|
+
<<~MSG.strip
|
|
568
|
+
No usable App Store Connect credentials found (missing: #{missing_pieces.join(', ')}).
|
|
569
|
+
|
|
570
|
+
Tried in order:
|
|
571
|
+
#{tried.map { |t| " - #{t}" }.join("\n")}
|
|
572
|
+
|
|
573
|
+
To fix, set ANY of these:
|
|
574
|
+
* Per-command flags: --asc-key-path PATH --asc-key-id KEY_ID --asc-issuer-id UUID
|
|
575
|
+
* Environment: APP_STORE_CONNECT_API_KEY_PATH, APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_API_KEY_ISSUER_ID
|
|
576
|
+
* Onboard locally: mysigner onboard --local-only
|
|
577
|
+
* Standard location: place AuthKey_<KEY_ID>.p8 in #{APPLE_PRIVATE_KEYS_DIR}/
|
|
578
|
+
MSG
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# ---- Android keystore cascade helpers ------------------------------
|
|
582
|
+
|
|
583
|
+
# Higher tiers never overwrite lower tiers when they're nil, and lower
|
|
584
|
+
# tiers never overwrite higher tiers when both have a value. That's the
|
|
585
|
+
# invariant `layer_android_*_pieces!` keeps: a key in `pieces` is set
|
|
586
|
+
# exactly once, by the highest tier that supplied it.
|
|
587
|
+
def layer_android_piece!(pieces, key, value, source:)
|
|
588
|
+
return if value.nil? || value.to_s.empty?
|
|
589
|
+
return unless pieces[key].nil?
|
|
590
|
+
|
|
591
|
+
pieces[key] = value
|
|
592
|
+
# Only the path drives the `:source` audit label — passwords and
|
|
593
|
+
# alias are metadata. Mirrors the ASC contract (path = primary
|
|
594
|
+
# material, issuer_id = metadata).
|
|
595
|
+
pieces[:source] ||= source if key == :path
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def layer_android_flag_pieces!(pieces, options)
|
|
599
|
+
layer_android_piece!(pieces, :path, expand_or_nil(string_option(options, :keystore_path)), source: :flag)
|
|
600
|
+
layer_android_piece!(pieces, :keystore_password, string_option(options, :keystore_password), source: :flag)
|
|
601
|
+
layer_android_piece!(pieces, :key_alias, string_option(options, :key_alias), source: :flag)
|
|
602
|
+
layer_android_piece!(pieces, :key_password, string_option(options, :key_password), source: :flag)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def layer_android_env_pieces!(pieces, env)
|
|
606
|
+
layer_android_piece!(pieces, :path,
|
|
607
|
+
expand_or_nil(string_env(env, 'MYSIGNER_KEYSTORE_PATH') || string_env(env, 'ANDROID_KEYSTORE_PATH')),
|
|
608
|
+
source: :env)
|
|
609
|
+
layer_android_piece!(pieces, :keystore_password,
|
|
610
|
+
string_env(env, 'MYSIGNER_KEYSTORE_PASSWORD') || string_env(env, 'ANDROID_KEYSTORE_PASSWORD'),
|
|
611
|
+
source: :env)
|
|
612
|
+
layer_android_piece!(pieces, :key_alias,
|
|
613
|
+
string_env(env, 'MYSIGNER_KEY_ALIAS') || string_env(env, 'ANDROID_KEY_ALIAS'),
|
|
614
|
+
source: :env)
|
|
615
|
+
layer_android_piece!(pieces, :key_password,
|
|
616
|
+
string_env(env, 'MYSIGNER_KEY_PASSWORD') || string_env(env, 'ANDROID_KEY_PASSWORD'),
|
|
617
|
+
source: :env)
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def layer_android_keychain_pieces!(pieces, hint:, tried:)
|
|
621
|
+
ids = safe_list_keychain(:android_keystore)
|
|
622
|
+
tried << "keychain: #{ids.length} entr#{ids.length == 1 ? 'y' : 'ies'}"
|
|
623
|
+
|
|
624
|
+
chosen_id = pick_android_keychain_id(ids, hint: hint)
|
|
625
|
+
return if chosen_id.nil?
|
|
626
|
+
|
|
627
|
+
envelope = fetch_keystore_envelope!(chosen_id)
|
|
628
|
+
|
|
629
|
+
# Materialize the base64-encoded .jks to a tmp file. Gradle and apksigner
|
|
630
|
+
# both expect a path, so we write once per process and keep the Tempfile
|
|
631
|
+
# alive on the Struct so GC doesn't unlink it mid-build.
|
|
632
|
+
tmp = materialize_keystore_tmpfile!(envelope.fetch('keystore_b64'), chosen_id)
|
|
633
|
+
|
|
634
|
+
layer_android_piece!(pieces, :path, tmp.path, source: :keychain)
|
|
635
|
+
layer_android_piece!(pieces, :keystore_password, envelope['keystore_password'], source: :keychain)
|
|
636
|
+
layer_android_piece!(pieces, :key_alias, envelope['key_alias'] || chosen_id, source: :keychain)
|
|
637
|
+
layer_android_piece!(pieces, :key_password, envelope['key_password'], source: :keychain)
|
|
638
|
+
pieces[:tmpfile] = tmp
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def layer_android_sniff_pieces!(pieces, cwd:, tried:)
|
|
642
|
+
return if pieces[:path] # higher tier already supplied a path
|
|
643
|
+
|
|
644
|
+
dir = File.expand_path(cwd)
|
|
645
|
+
# Manual loop (not `.times`) so we can `break` out cleanly on the
|
|
646
|
+
# first match without a non-local return-from-iterator (Lint cop).
|
|
647
|
+
# Per-dir order: highest-priority project-local explicit (key.properties,
|
|
648
|
+
# the Flutter / Gradle-only standard) → eas.json (Expo) → inline
|
|
649
|
+
# `signingConfigs.release` in `android/app/build.gradle[.kts]` (the
|
|
650
|
+
# most common pure-Android-Studio convention; later in order because
|
|
651
|
+
# `key.properties` is more explicit and many Studio projects load it
|
|
652
|
+
# via `keystoreProperties[...]` inside the gradle file anyway, in
|
|
653
|
+
# which case the .properties file is the source of truth).
|
|
654
|
+
depth = 0
|
|
655
|
+
while depth < PROJECT_SNIFF_MAX_DEPTH
|
|
656
|
+
tried << "disk: project-sniff in #{dir}"
|
|
657
|
+
|
|
658
|
+
break if try_layer_android_key_properties!(pieces, dir)
|
|
659
|
+
break if try_layer_android_eas_json!(pieces, dir)
|
|
660
|
+
break if try_layer_android_build_gradle!(pieces, dir)
|
|
661
|
+
|
|
662
|
+
parent = File.dirname(dir)
|
|
663
|
+
break if parent == dir
|
|
664
|
+
|
|
665
|
+
dir = parent
|
|
666
|
+
depth += 1
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Global per-user fallback: only consulted if project-local sources
|
|
670
|
+
# didn't fill every piece. Scoped by `pieces[:path].nil?` so a project
|
|
671
|
+
# that fully self-describes its keystore via key.properties / eas.json /
|
|
672
|
+
# build.gradle never silently picks up a stale entry from a developer's
|
|
673
|
+
# ~/.gradle/gradle.properties.
|
|
674
|
+
try_layer_gradle_properties!(pieces, tried) if pieces[:path].nil?
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def try_layer_android_key_properties!(pieces, dir)
|
|
678
|
+
ANDROID_KEY_PROPERTIES_FILES.each do |rel|
|
|
679
|
+
kp_path = File.join(dir, rel)
|
|
680
|
+
next unless File.exist?(kp_path)
|
|
681
|
+
|
|
682
|
+
kp = parse_key_properties(kp_path)
|
|
683
|
+
next unless kp[:storeFile]
|
|
684
|
+
|
|
685
|
+
resolved = File.expand_path(kp[:storeFile], File.dirname(kp_path))
|
|
686
|
+
next unless File.exist?(resolved)
|
|
687
|
+
|
|
688
|
+
layer_android_piece!(pieces, :path, resolved, source: :disk)
|
|
689
|
+
layer_android_piece!(pieces, :keystore_password, kp[:storePassword], source: :disk)
|
|
690
|
+
layer_android_piece!(pieces, :key_alias, kp[:keyAlias], source: :disk)
|
|
691
|
+
layer_android_piece!(pieces, :key_password, kp[:keyPassword], source: :disk)
|
|
692
|
+
return true
|
|
693
|
+
end
|
|
694
|
+
false
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def try_layer_android_eas_json!(pieces, dir)
|
|
698
|
+
eas_path = File.join(dir, 'eas.json')
|
|
699
|
+
return false unless File.exist?(eas_path)
|
|
700
|
+
|
|
701
|
+
extract_keystore_from_eas!(pieces, eas_path, dir)
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# mysigner-22 Phase 7 follow-up — pure-Android-Studio convention.
|
|
705
|
+
# Inline `signingConfigs { release { ... } }` in `android/app/build.gradle`
|
|
706
|
+
# (Groovy DSL) or `android/app/build.gradle.kts` (Kotlin DSL).
|
|
707
|
+
#
|
|
708
|
+
# We deliberately do NOT write a Groovy/Kotlin parser; we extract literal
|
|
709
|
+
# string values for the four `release {}` fields and skip anything that
|
|
710
|
+
# is a `System.getenv(...)`, `project.properties[...]`, or
|
|
711
|
+
# `keystoreProperties[...]` reference — those are handled by the env tier
|
|
712
|
+
# and the key.properties sniff respectively.
|
|
713
|
+
#
|
|
714
|
+
# WHY source :disk (not a new :gradle): the user-facing audit attribution
|
|
715
|
+
# is "we found credentials on disk in your project tree", same conceptual
|
|
716
|
+
# tier as key.properties and eas.json. Adding a new source label would
|
|
717
|
+
# bloat the audit log for no operational benefit — the file extension
|
|
718
|
+
# already disambiguates in tried_log.
|
|
719
|
+
def try_layer_android_build_gradle!(pieces, dir)
|
|
720
|
+
%w[android/app/build.gradle android/app/build.gradle.kts].each do |rel|
|
|
721
|
+
gradle_path = File.join(dir, rel)
|
|
722
|
+
next unless File.exist?(gradle_path)
|
|
723
|
+
|
|
724
|
+
release_body = extract_gradle_release_block(gradle_path)
|
|
725
|
+
next if release_body.nil? || release_body.empty?
|
|
726
|
+
|
|
727
|
+
extracted = extract_gradle_release_fields(release_body)
|
|
728
|
+
# Resolve storeFile relative to the gradle file's own dir (matches
|
|
729
|
+
# Gradle's `file(...)` resolution semantics).
|
|
730
|
+
if extracted[:storeFile]
|
|
731
|
+
resolved = File.expand_path(extracted[:storeFile], File.dirname(gradle_path))
|
|
732
|
+
layer_android_piece!(pieces, :path, resolved, source: :disk) if File.exist?(resolved)
|
|
733
|
+
end
|
|
734
|
+
layer_android_piece!(pieces, :keystore_password, extracted[:storePassword], source: :disk)
|
|
735
|
+
layer_android_piece!(pieces, :key_alias, extracted[:keyAlias], source: :disk)
|
|
736
|
+
layer_android_piece!(pieces, :key_password, extracted[:keyPassword], source: :disk)
|
|
737
|
+
|
|
738
|
+
return true if pieces[:path]
|
|
739
|
+
end
|
|
740
|
+
false
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Match `signingConfigs { ... release { <body> } ... }` and return the
|
|
744
|
+
# inner release-block body. Tolerant of whitespace; uses a brace-depth
|
|
745
|
+
# walk (NOT a regex with nested capture, which can't match balanced
|
|
746
|
+
# braces) so deeply nested debug { ... } release { ... } structures
|
|
747
|
+
# don't trip up the extraction.
|
|
748
|
+
def extract_gradle_release_block(path)
|
|
749
|
+
src = File.read(path)
|
|
750
|
+
return nil unless src
|
|
751
|
+
|
|
752
|
+
signing_body = extract_balanced_brace_body(src, /signingConfigs\s*\{/)
|
|
753
|
+
return nil if signing_body.nil?
|
|
754
|
+
|
|
755
|
+
# Match three release-block forms:
|
|
756
|
+
# Groovy DSL: release {
|
|
757
|
+
# Kotlin DSL: create("release") { ... } OR getByName("release") { ... }
|
|
758
|
+
# Both Kotlin variants are how Android Studio's New Project wizard
|
|
759
|
+
# actually writes signingConfigs in build.gradle.kts — bare `release {`
|
|
760
|
+
# is illegal Kotlin (no such top-level identifier). Without the
|
|
761
|
+
# `create|getByName` alternation, every modern AS Kotlin project
|
|
762
|
+
# silently fell through to the prompt tier.
|
|
763
|
+
extract_balanced_brace_body(
|
|
764
|
+
signing_body,
|
|
765
|
+
/(?:release|(?:create|getByName)\s*\(\s*["']release["']\s*\))\s*\{/
|
|
766
|
+
)
|
|
767
|
+
rescue StandardError
|
|
768
|
+
nil
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Find the first match of `opener_regex` ending at `{`, then walk forward
|
|
772
|
+
# counting braces until the matching `}`. Returns the body BETWEEN the
|
|
773
|
+
# braces (exclusive). Returns nil if no match or unbalanced braces.
|
|
774
|
+
def extract_balanced_brace_body(src, opener_regex)
|
|
775
|
+
m = src.match(opener_regex)
|
|
776
|
+
return nil unless m
|
|
777
|
+
|
|
778
|
+
start_idx = m.end(0) # position right after the `{`
|
|
779
|
+
depth = 1
|
|
780
|
+
idx = start_idx
|
|
781
|
+
while idx < src.length
|
|
782
|
+
ch = src[idx]
|
|
783
|
+
depth += 1 if ch == '{'
|
|
784
|
+
depth -= 1 if ch == '}'
|
|
785
|
+
return src[start_idx...idx] if depth.zero?
|
|
786
|
+
|
|
787
|
+
idx += 1
|
|
788
|
+
end
|
|
789
|
+
nil
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# Pull the four literal field values from a release-block body. The
|
|
793
|
+
# patterns intentionally only match double / single quoted string
|
|
794
|
+
# literals — `System.getenv(...)`, `project.properties[...]`,
|
|
795
|
+
# `keystoreProperties[...]` references all fall through (no match) and
|
|
796
|
+
# are left for the env tier / key.properties sniff to fill.
|
|
797
|
+
#
|
|
798
|
+
# Optional `=` between identifier and value handles the Kotlin DSL form
|
|
799
|
+
# (`storePassword = "pw"`) without a second pattern set.
|
|
800
|
+
def extract_gradle_release_fields(body)
|
|
801
|
+
out = {}
|
|
802
|
+
# storeFile: must be `storeFile [= ]file("<literal>")`. We require the
|
|
803
|
+
# `file(` wrapper because that's what every Android signing config uses
|
|
804
|
+
# for path values; bare strings here would be wrong syntax.
|
|
805
|
+
if (m = body.match(/storeFile\s*=?\s*file\(\s*["']([^"']+)["']\s*\)/))
|
|
806
|
+
out[:storeFile] = m[1]
|
|
807
|
+
end
|
|
808
|
+
if (m = body.match(/storePassword\s*=?\s*["']([^"']+)["']/))
|
|
809
|
+
out[:storePassword] = m[1]
|
|
810
|
+
end
|
|
811
|
+
if (m = body.match(/keyAlias\s*=?\s*["']([^"']+)["']/))
|
|
812
|
+
out[:keyAlias] = m[1]
|
|
813
|
+
end
|
|
814
|
+
if (m = body.match(/keyPassword\s*=?\s*["']([^"']+)["']/))
|
|
815
|
+
out[:keyPassword] = m[1]
|
|
816
|
+
end
|
|
817
|
+
out
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# mysigner-22 Phase 7 follow-up — Android Studio per-user convention.
|
|
821
|
+
# The "secure shared keystore" pattern from
|
|
822
|
+
# https://developer.android.com/studio/publish/app-signing#secure-shared-keystore
|
|
823
|
+
# stores signing config in `~/.gradle/gradle.properties` with a project-
|
|
824
|
+
# chosen prefix:
|
|
825
|
+
#
|
|
826
|
+
# MYAPP_RELEASE_STORE_FILE=/path/to/release.jks
|
|
827
|
+
# MYAPP_RELEASE_STORE_PASSWORD=...
|
|
828
|
+
# MYAPP_RELEASE_KEY_ALIAS=upload
|
|
829
|
+
# MYAPP_RELEASE_KEY_PASSWORD=...
|
|
830
|
+
#
|
|
831
|
+
# The docs use `MYAPP_RELEASE_` as the example prefix but each project
|
|
832
|
+
# picks its own. We scan all keys, group by prefix, and:
|
|
833
|
+
# - if exactly one prefix has all four fields → layer it in
|
|
834
|
+
# - if multiple prefixes are full → raise AmbiguousCredentialsError
|
|
835
|
+
# - if only partial sets exist → skip silently and let other tiers fill
|
|
836
|
+
def try_layer_gradle_properties!(pieces, tried)
|
|
837
|
+
gradle_props_path = File.expand_path('~/.gradle/gradle.properties')
|
|
838
|
+
return unless File.exist?(gradle_props_path)
|
|
839
|
+
|
|
840
|
+
tried << "disk: #{gradle_props_path}"
|
|
841
|
+
|
|
842
|
+
parsed = parse_key_properties(gradle_props_path)
|
|
843
|
+
return if parsed.empty?
|
|
844
|
+
|
|
845
|
+
full_prefixes = group_gradle_keystore_prefixes(parsed)
|
|
846
|
+
|
|
847
|
+
if full_prefixes.length > 1
|
|
848
|
+
raise AmbiguousCredentialsError,
|
|
849
|
+
"Multiple Android keystore prefixes in #{gradle_props_path} " \
|
|
850
|
+
"(#{full_prefixes.keys.sort.join(', ')}). " \
|
|
851
|
+
'Pass --key-alias ALIAS (or set MYSIGNER_KEY_ALIAS) to disambiguate, ' \
|
|
852
|
+
'or remove the unused prefix entries.'
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
return if full_prefixes.empty?
|
|
856
|
+
|
|
857
|
+
_prefix, fields = full_prefixes.first
|
|
858
|
+
resolved_path = File.expand_path(fields[:store_file])
|
|
859
|
+
return unless File.exist?(resolved_path)
|
|
860
|
+
|
|
861
|
+
layer_android_piece!(pieces, :path, resolved_path, source: :disk)
|
|
862
|
+
layer_android_piece!(pieces, :keystore_password, fields[:store_password], source: :disk)
|
|
863
|
+
layer_android_piece!(pieces, :key_alias, fields[:key_alias], source: :disk)
|
|
864
|
+
layer_android_piece!(pieces, :key_password, fields[:key_password], source: :disk)
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# Walk every parsed key, bin by prefix-before-_STORE_FILE (and the three
|
|
868
|
+
# siblings). Returns only prefixes that have ALL four fields present
|
|
869
|
+
# AND non-empty — partials get filtered out so they don't trigger the
|
|
870
|
+
# ambiguity check (a partial set is "noise", not a competing candidate).
|
|
871
|
+
def group_gradle_keystore_prefixes(parsed)
|
|
872
|
+
buckets = Hash.new { |h, k| h[k] = {} }
|
|
873
|
+
parsed.each do |key, value|
|
|
874
|
+
next if value.nil? || value.to_s.empty?
|
|
875
|
+
|
|
876
|
+
key_str = key.to_s
|
|
877
|
+
case key_str
|
|
878
|
+
when /\A(.+)_STORE_FILE\z/ then buckets[Regexp.last_match(1)][:store_file] = value
|
|
879
|
+
when /\A(.+)_STORE_PASSWORD\z/ then buckets[Regexp.last_match(1)][:store_password] = value
|
|
880
|
+
when /\A(.+)_KEY_ALIAS\z/ then buckets[Regexp.last_match(1)][:key_alias] = value
|
|
881
|
+
when /\A(.+)_KEY_PASSWORD\z/ then buckets[Regexp.last_match(1)][:key_password] = value
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
required = %i[store_file store_password key_alias key_password]
|
|
886
|
+
buckets.select { |_, fields| required.all? { |f| fields[f] } }
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# `key.properties` is INI-ish: key=value lines, `#` comments, blank
|
|
890
|
+
# lines OK. The Flutter docs explicitly use four keys: storeFile,
|
|
891
|
+
# storePassword, keyAlias, keyPassword. We deliberately don't pull in
|
|
892
|
+
# an ini-parser gem — the format is two lines of regex.
|
|
893
|
+
#
|
|
894
|
+
# Strips a single leading + single trailing matching quote pair after
|
|
895
|
+
# `.strip` — `storePassword="my pw"` is valid `.properties` syntax used
|
|
896
|
+
# in many Android docs examples; without this, the literal 8-char string
|
|
897
|
+
# `"my pw"` (quotes included) flows through to apksigner and is rejected.
|
|
898
|
+
# Mirrors the `prompt` helper's de-quoting for parity.
|
|
899
|
+
def parse_key_properties(path)
|
|
900
|
+
out = {}
|
|
901
|
+
File.foreach(path) do |line|
|
|
902
|
+
line = line.strip
|
|
903
|
+
next if line.empty? || line.start_with?('#', ';')
|
|
904
|
+
|
|
905
|
+
key, _, value = line.partition('=')
|
|
906
|
+
next if value.empty?
|
|
907
|
+
|
|
908
|
+
value = value.strip.gsub(/\A['"]|['"]\z/, '')
|
|
909
|
+
out[key.strip.to_sym] = value
|
|
910
|
+
end
|
|
911
|
+
out
|
|
912
|
+
rescue StandardError
|
|
913
|
+
{}
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
# eas.json — Expo's android keystore lives at:
|
|
917
|
+
# credentials.android.keystore.{keystorePath, keystorePassword, keyAlias, keyPassword}
|
|
918
|
+
# Same profile-walk as the Play SA-JSON variant: production → preview → default.
|
|
919
|
+
def extract_keystore_from_eas!(pieces, eas_path, dir)
|
|
920
|
+
json = JSON.parse(File.read(eas_path))
|
|
921
|
+
ks = json.dig('credentials', 'android', 'keystore')
|
|
922
|
+
return false unless ks.is_a?(Hash)
|
|
923
|
+
|
|
924
|
+
rel_path = ks['keystorePath']
|
|
925
|
+
return false if rel_path.nil? || rel_path.to_s.strip.empty?
|
|
926
|
+
|
|
927
|
+
resolved = File.expand_path(rel_path, dir)
|
|
928
|
+
return false unless File.exist?(resolved)
|
|
929
|
+
|
|
930
|
+
layer_android_piece!(pieces, :path, resolved, source: :disk)
|
|
931
|
+
layer_android_piece!(pieces, :keystore_password, ks['keystorePassword'], source: :disk)
|
|
932
|
+
layer_android_piece!(pieces, :key_alias, ks['keyAlias'], source: :disk)
|
|
933
|
+
layer_android_piece!(pieces, :key_password, ks['keyPassword'], source: :disk)
|
|
934
|
+
true
|
|
935
|
+
rescue JSON::ParserError
|
|
936
|
+
false
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def fetch_keystore_envelope!(id)
|
|
940
|
+
require 'mysigner/local_credentials'
|
|
941
|
+
raw = Mysigner::LocalCredentials.fetch(kind: :android_keystore, id: id)
|
|
942
|
+
if raw.nil?
|
|
943
|
+
raise CredentialNotFoundError,
|
|
944
|
+
"Keychain index lists Android keystore `#{id}` but the secret is missing. " \
|
|
945
|
+
'Re-store with `mysigner onboard --local-only`.'
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
parsed = begin
|
|
949
|
+
JSON.parse(raw)
|
|
950
|
+
rescue JSON::ParserError => e
|
|
951
|
+
raise CredentialNotFoundError,
|
|
952
|
+
"Android keystore Keychain entry `#{id}` is not valid JSON (#{e.message}). " \
|
|
953
|
+
'Re-store with `mysigner onboard --local-only`.'
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
unless parsed.is_a?(Hash) && parsed['keystore_b64']
|
|
957
|
+
raise CredentialNotFoundError,
|
|
958
|
+
"Android keystore Keychain entry `#{id}` is missing required `keystore_b64`. " \
|
|
959
|
+
'Re-store with `mysigner onboard --local-only`.'
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
parsed
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
# WHY Tempfile (not Tempfile.create) + held on the Struct: the build
|
|
966
|
+
# pipeline runs in the same Ruby process, so the Tempfile finalizer
|
|
967
|
+
# cleans up at process exit. If we used Tempfile.create with a block,
|
|
968
|
+
# the file would unlink immediately. If we just dropped the reference,
|
|
969
|
+
# GC could unlink mid-build. Holding it on the Struct ties lifetime
|
|
970
|
+
# to the caller's grip on AndroidKeystoreCreds.
|
|
971
|
+
def materialize_keystore_tmpfile!(b64, id)
|
|
972
|
+
bytes = Base64.strict_decode64(b64)
|
|
973
|
+
safe_id = id.to_s.gsub(/[^a-zA-Z0-9._-]/, '_')
|
|
974
|
+
tmp = Tempfile.new(["mysigner-keystore-#{safe_id}-", '.jks'])
|
|
975
|
+
tmp.binmode
|
|
976
|
+
tmp.write(bytes)
|
|
977
|
+
tmp.flush
|
|
978
|
+
File.chmod(0o600, tmp.path)
|
|
979
|
+
tmp
|
|
980
|
+
rescue ArgumentError => e
|
|
981
|
+
raise CredentialNotFoundError,
|
|
982
|
+
"Android keystore Keychain entry `#{id}` has invalid base64 (#{e.message}). " \
|
|
983
|
+
'Re-store with `mysigner onboard --local-only`.'
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
def android_missing_pieces(pieces)
|
|
987
|
+
out = []
|
|
988
|
+
out << :path if pieces[:path].nil?
|
|
989
|
+
out << :keystore_password if pieces[:keystore_password].nil?
|
|
990
|
+
out << :key_alias if pieces[:key_alias].nil?
|
|
991
|
+
# key_password is optional — defaults to keystore_password when nil
|
|
992
|
+
# (long-standing Gradle convention). Don't force a separate prompt.
|
|
993
|
+
out
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
def fill_android_pieces_from_prompt!(pieces, missing, stdin:, stderr:)
|
|
997
|
+
if missing.include?(:path)
|
|
998
|
+
path = prompt(stderr, stdin, 'Path to your Android keystore (.jks / .keystore):')
|
|
999
|
+
pieces[:path] = File.expand_path(path)
|
|
1000
|
+
pieces[:source] ||= :prompt
|
|
1001
|
+
unless File.exist?(pieces[:path])
|
|
1002
|
+
raise CredentialNotFoundError,
|
|
1003
|
+
"Android keystore not found at #{pieces[:path]} (from prompt). " \
|
|
1004
|
+
'Check the path and try again.'
|
|
1005
|
+
end
|
|
1006
|
+
end
|
|
1007
|
+
if missing.include?(:keystore_password)
|
|
1008
|
+
pieces[:keystore_password] = prompt_password(stderr, stdin, 'Keystore password:')
|
|
1009
|
+
# Ask for an optional distinct key_password. Without this, users
|
|
1010
|
+
# whose key has a different password than the keystore get a silent
|
|
1011
|
+
# fuse to keystore_password (via the final `||` defaulting in
|
|
1012
|
+
# `resolve_android_keystore`) and a downstream apksigner failure
|
|
1013
|
+
# ("incorrect key password") with no hint. Empty input → keep nil
|
|
1014
|
+
# so the existing default kicks in.
|
|
1015
|
+
if pieces[:key_password].nil?
|
|
1016
|
+
key_pw = prompt_password(stderr, stdin, 'Key password (press Enter to reuse keystore password):')
|
|
1017
|
+
pieces[:key_password] = key_pw unless key_pw.nil? || key_pw.empty?
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
return unless missing.include?(:key_alias)
|
|
1021
|
+
|
|
1022
|
+
pieces[:key_alias] = prompt(stderr, stdin, 'Key alias:')
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
# Password prompt — disables terminal echo so the password doesn't show
|
|
1026
|
+
# in the user's scrollback. Falls back to a plain prompt when echo
|
|
1027
|
+
# control isn't available (non-TTY tests, weird terminals). The prompt
|
|
1028
|
+
# is gated by the same TTY check the caller already does, so the
|
|
1029
|
+
# fallback path is mostly for instance_double stdin in specs.
|
|
1030
|
+
def prompt_password(stderr, stdin, label)
|
|
1031
|
+
# `noecho` lives in io/console — load lazily so test doubles that
|
|
1032
|
+
# don't implement it aren't required to.
|
|
1033
|
+
begin
|
|
1034
|
+
require 'io/console'
|
|
1035
|
+
rescue LoadError
|
|
1036
|
+
# If io/console is unavailable, fall back silently.
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
return prompt(stderr, stdin, label) unless stdin.respond_to?(:noecho)
|
|
1040
|
+
|
|
1041
|
+
stderr.print "#{label} " if stderr.respond_to?(:print)
|
|
1042
|
+
value = stdin.noecho(&:gets).to_s.strip
|
|
1043
|
+
stderr.puts if stderr.respond_to?(:puts)
|
|
1044
|
+
value
|
|
1045
|
+
rescue IOError
|
|
1046
|
+
prompt(stderr, stdin, label)
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def expand_or_nil(path)
|
|
1050
|
+
return nil if path.nil?
|
|
1051
|
+
|
|
1052
|
+
File.expand_path(path)
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
def android_keystore_not_found_message(tried:, missing:)
|
|
1056
|
+
missing_labels = missing.map { |m| ANDROID_MISSING_LABELS[m] }
|
|
1057
|
+
|
|
1058
|
+
<<~MSG.strip
|
|
1059
|
+
No usable Android keystore found (missing: #{missing_labels.join(', ')}).
|
|
1060
|
+
|
|
1061
|
+
Tried in order:
|
|
1062
|
+
#{tried.map { |t| " - #{t}" }.join("\n")}
|
|
1063
|
+
|
|
1064
|
+
To fix, set ANY of these:
|
|
1065
|
+
* Per-command flags: --keystore-path PATH --keystore-password PWD
|
|
1066
|
+
--key-alias ALIAS --key-password PWD
|
|
1067
|
+
* Environment: MYSIGNER_KEYSTORE_PATH (or ANDROID_KEYSTORE_PATH)
|
|
1068
|
+
MYSIGNER_KEYSTORE_PASSWORD (or ANDROID_KEYSTORE_PASSWORD)
|
|
1069
|
+
MYSIGNER_KEY_ALIAS (or ANDROID_KEY_ALIAS)
|
|
1070
|
+
MYSIGNER_KEY_PASSWORD (or ANDROID_KEY_PASSWORD)
|
|
1071
|
+
* Onboard locally: mysigner onboard --local-only
|
|
1072
|
+
* Project file: android/key.properties (Flutter convention),
|
|
1073
|
+
android/keystore.properties, or key.properties at project root,
|
|
1074
|
+
with storeFile=, storePassword=, keyAlias=, keyPassword=,
|
|
1075
|
+
or credentials.android.keystore.* in eas.json,
|
|
1076
|
+
or inline signingConfigs.release in android/app/build.gradle[.kts]
|
|
1077
|
+
* Global gradle: ~/.gradle/gradle.properties with PREFIX_STORE_FILE,
|
|
1078
|
+
PREFIX_STORE_PASSWORD, PREFIX_KEY_ALIAS, PREFIX_KEY_PASSWORD
|
|
1079
|
+
MSG
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
def play_not_found_message(tried:)
|
|
1083
|
+
<<~MSG.strip
|
|
1084
|
+
No usable Google Play credentials found.
|
|
1085
|
+
|
|
1086
|
+
Tried in order:
|
|
1087
|
+
#{tried.map { |t| " - #{t}" }.join("\n")}
|
|
1088
|
+
|
|
1089
|
+
To fix, set ANY of these:
|
|
1090
|
+
* Per-command flag: --play-credentials PATH
|
|
1091
|
+
* Environment: GOOGLE_APPLICATION_CREDENTIALS
|
|
1092
|
+
* Onboard locally: mysigner onboard --local-only
|
|
1093
|
+
* Project file: place service-account.json (or play-credentials.json) at your project root,
|
|
1094
|
+
or add submit.<profile>.android.serviceAccountKeyPath to eas.json
|
|
1095
|
+
MSG
|
|
1096
|
+
end
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
end
|