Module: Mysigner::CredentialResolver

Defined in:
lib/mysigner/credential_resolver.rb

Overview

mysigner-22 Phase 5 — credential auto-discovery cascade for ‘–local-only` mode. Replaces the original Keychain-only lookup with a fastlane-style cascade: explicit per-command flags → env vars users already set (Apple’s APP_STORE_CONNECT_API_KEY_* and Google’s GOOGLE_APPLICATION_CREDENTIALS) →Keychain (‘onboard –local-only` store) → standard on-disk locations →interactive prompt (TTY only — never in CI).

The resolver is the ONLY place that knows about the cascade. Uploaders receive the resolved Struct and don’t touch ENV / disk / prompts directly, which keeps the wiring testable (Rule 9: tests verify the cascade, not the uploader plumbing).

Backward-compat contract: vault mode (no ‘–local-only`) never calls this module. The `LocalCredentials` API is unchanged — it just becomes one source in the cascade rather than the only one.

Defined Under Namespace

Classes: AmbiguousCredentialsError, AndroidKeystoreCreds, AscCreds, CredentialNotFoundError, PlayCreds

Constant Summary collapse

APPLE_PRIVATE_KEYS_DIR =

Apple’s officially-blessed on-disk location for ASC private keys, documented in WWDC sessions and used by altool / xcrun / fastlane.

File.expand_path('~/.appstoreconnect/private_keys').freeze
PLAY_PROJECT_FILE_NAMES =

Common file names users put service-account JSON under at a project root.

%w[
  play-credentials.json
  service-account.json
  play-service-account.json
].freeze
PROJECT_SNIFF_MAX_DEPTH =

Walk up at most this many parent directories looking for project-sniff files. Three levels covers the common monorepo-with-app-subdir layout without surprising the user by reaching into unrelated trees.

3
ANDROID_KEY_PROPERTIES_FILES =

Project-sniff filenames for Android signing config. ‘android/key.properties` is the Flutter convention (the `flutter create` template writes it); `android/keystore.properties` and a root-level `key.properties` are seen in a few Gradle-only setups (the docs use both names interchangeably). First match wins.

[
  'android/key.properties',
  'android/keystore.properties',
  'key.properties'
].freeze
ANDROID_MISSING_LABELS =

Human-readable labels for the four cascade pieces. Used by the not-found error message so each ‘missing` symbol prints something the user can map back to a flag/env/sniff-key.

{
  path: 'keystore path',
  keystore_password: 'keystore password',
  key_alias: 'key alias',
  key_password: 'key password'
}.freeze

Class Method Summary collapse

Class Method Details

.resolve_android_keystore(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd) ⇒ AndroidKeystoreCreds

mysigner-22 Phase 7 — Android keystore cascade.

Each tier may contribute any subset of the four required pieces (path, keystore_password, key_alias, key_password); the resolver stitches them together highest-priority-first and prompts (TTY) or fails (non-TTY) for whatever is still missing.

Priority: flag > env > keychain > project-sniff > prompt.



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/mysigner/credential_resolver.rb', line 251

def resolve_android_keystore(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd)
  tried = []
  # Layered hash {path:, keystore_password:, key_alias:, key_password:, source:, tmpfile:}.
  # `path_source` mirrors the ASC cascade contract: source attribution
  # follows the tier that supplied the .jks (the primary material), not
  # the tier that filled in a stray password field.
  pieces = {}

  layer_android_flag_pieces!(pieces, options)
  layer_android_env_pieces!(pieces, env)
  layer_android_keychain_pieces!(pieces, hint: pieces[:key_alias], tried: tried)
  layer_android_sniff_pieces!(pieces, cwd: cwd, tried: tried)

  # All tiers exhausted — prompt for what's still missing (TTY only).
  missing = android_missing_pieces(pieces)
  if missing.any?
    unless stdin.respond_to?(:tty?) && stdin.tty?
      raise CredentialNotFoundError, android_keystore_not_found_message(tried: tried, missing: missing)
    end

    fill_android_pieces_from_prompt!(pieces, missing, stdin: stdin, stderr: stderr)
  end

  AndroidKeystoreCreds.new(
    keystore_path: pieces[:path],
    keystore_password: pieces[:keystore_password],
    key_alias: pieces[:key_alias],
    key_password: pieces[:key_password] || pieces[:keystore_password],
    source: pieces[:source] || :prompt,
    tmpfile: pieces[:tmpfile]
  )
end

.resolve_asc(options: {}, env: ENV, stdin: $stdin, stderr: $stderr) ⇒ AscCreds

Parameters:

  • options (Hash) (defaults to: {})

    Thor options hash with –asc-key-path/id/issuer-id

  • env (Hash) (defaults to: ENV)

    ENV substitute for testability

  • stdin (IO) (defaults to: $stdin)

    $stdin substitute (we check #tty? for prompt gating)

  • stderr (IO) (defaults to: $stderr)

    $stderr substitute (for the prompt itself)

Returns:

Raises:



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/mysigner/credential_resolver.rb', line 94

def resolve_asc(options: {}, env: ENV, stdin: $stdin, stderr: $stderr)
  tried = []

  # Tier 1: per-command CLI flags (highest precedence). If all three are
  # present we short-circuit; partial flags layer in below.
  flag_path   = string_option(options, :asc_key_path)
  flag_key_id = string_option(options, :asc_key_id)
  flag_issuer = string_option(options, :asc_issuer_id)
  if flag_path && flag_key_id && flag_issuer
    pem = read_pem!(flag_path, label: '--asc-key-path')
    tried << "flag: --asc-key-path=#{flag_path}"
    return AscCreds.new(key_id: flag_key_id, issuer_id: flag_issuer, p8_pem: pem, source: :flag)
  end

  # Tier 2: env vars (fastlane convention).
  env_path   = string_env(env, 'APP_STORE_CONNECT_API_KEY_PATH')
  env_key_id = string_env(env, 'APP_STORE_CONNECT_API_KEY_ID')
  env_issuer = string_env(env, 'APP_STORE_CONNECT_API_KEY_ISSUER_ID')

  # Tier 3: Keychain — list yields zero / one / many; "many without
  # disambiguator" is an explicit ambiguous-error rather than silently
  # picking the first. WHY: the old uploader took .first quietly, which
  # was fine when only `onboard --local-only` could write entries but is
  # dangerous now that users may layer flags/env on top.
  keychain_ids = safe_list_keychain(:asc)
  keychain_id  = pick_keychain_id(keychain_ids, hint: flag_key_id || env_key_id, label: 'ASC')
  tried << "keychain: #{keychain_ids.length} entr#{keychain_ids.length == 1 ? 'y' : 'ies'}"

  # Tier 4: disk scan. Skip entirely when a higher tier (flag or env)
  # already supplies a .p8 path — disk can't add anything we'd prefer.
  # WHY: scan_apple_private_keys_dir raises AmbiguousCredentialsError on
  # multiple .p8 files, and that error is misleading when the user has
  # already pointed at one via --asc-key-path / APP_STORE_CONNECT_API_KEY_PATH.
  # "Higher tier wins" means the lower tier shouldn't even probe.
  disk_path, disk_key_id =
    if flag_path || env_path
      [nil, nil]
    else
      scan_apple_private_keys_dir(tried)
    end

  # Stitch together the highest-priority *partial* tier first, then fill
  # the missing fields from lower tiers. This lets "disk found the .p8
  # but no issuer_id env" continue down to env then to prompt without
  # restarting the cascade. Pieces are passed as one hash to keep the
  # helper under the parameter-list cop limit.
  path, key_id, issuer_id, source = assemble_asc_pieces(
    flag_path: flag_path, flag_key_id: flag_key_id, flag_issuer: flag_issuer,
    env_path: env_path, env_key_id: env_key_id, env_issuer: env_issuer,
    keychain_id: keychain_id, disk_path: disk_path, disk_key_id: disk_key_id
  )

  # Keychain shortcut: when keychain holds the (key_id, issuer_id, pem)
  # triple we don't need disk/env for anything.
  if source == :keychain
    envelope = fetch_keychain_envelope(:asc, key_id, label: 'ASC')
    return AscCreds.new(
      key_id: key_id,
      issuer_id: envelope.fetch('issuer_id'),
      p8_pem: envelope.fetch('p8_pem'),
      source: :keychain
    )
  end

  # All other tiers need a PEM read from disk.
  pem = path ? read_pem!(path, label: source_label(source)) : nil

  # Final fallback: prompt only when STDIN is a TTY. CI must fail loud.
  if path.nil? || key_id.nil? || issuer_id.nil?
    unless stdin.respond_to?(:tty?) && stdin.tty?
      raise CredentialNotFoundError, asc_not_found_message(
        tried: tried,
        missing: { path: path.nil?, key_id: key_id.nil?, issuer_id: issuer_id.nil? }
      )
    end

    if path.nil?
      path = prompt(stderr, stdin, 'Path to your App Store Connect .p8 private key:')
      pem = read_pem!(File.expand_path(path), label: 'prompt')
    end
    key_id    ||= derive_key_id_from_filename(path) || prompt(stderr, stdin, 'App Store Connect Key ID:')
    issuer_id ||= prompt(stderr, stdin, 'App Store Connect Issuer ID (UUID):')
    # Only overwrite source to :prompt when the .p8 path itself was
    # prompted (source.nil? means no higher tier supplied it). When the
    # path came from env/flag/keychain/disk and only issuer_id was
    # prompted in, preserve the originating source — the audit log
    # should attribute the credential to where the primary material
    # (the .p8) came from; issuer_id is metadata.
    source ||= :prompt
  end

  AscCreds.new(key_id: key_id, issuer_id: issuer_id, p8_pem: pem, source: source)
end

.resolve_play(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd) ⇒ PlayCreds

Parameters:

  • options (Hash) (defaults to: {})

    Thor options with –play-credentials

  • env (Hash) (defaults to: ENV)
  • stdin (IO) (defaults to: $stdin)
  • stderr (IO) (defaults to: $stderr)
  • cwd (String) (defaults to: Dir.pwd)

    starting dir for project-sniff (Dir.pwd in prod)

Returns:

Raises:



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/mysigner/credential_resolver.rb', line 195

def resolve_play(options: {}, env: ENV, stdin: $stdin, stderr: $stderr, cwd: Dir.pwd)
  tried = []

  # Tier 1: flag.
  if (flag_path = string_option(options, :play_credentials))
    tried << "flag: --play-credentials=#{flag_path}"
    raw, email = read_sa_json!(flag_path, label: '--play-credentials')
    return PlayCreds.new(sa_json: raw, client_email: email, source: :flag)
  end

  # Tier 2: env (Google's documented convention).
  if (env_path = string_env(env, 'GOOGLE_APPLICATION_CREDENTIALS'))
    tried << "env: GOOGLE_APPLICATION_CREDENTIALS=#{env_path}"
    raw, email = read_sa_json!(env_path, label: 'GOOGLE_APPLICATION_CREDENTIALS')
    return PlayCreds.new(sa_json: raw, client_email: email, source: :env)
  end

  # Tier 3: Keychain.
  keychain_ids = safe_list_keychain(:google_play)
  tried << "keychain: #{keychain_ids.length} entr#{keychain_ids.length == 1 ? 'y' : 'ies'}"
  if keychain_ids.length == 1
    raw = fetch_keychain_raw(:google_play, keychain_ids.first, label: 'Google Play')
    return PlayCreds.new(sa_json: raw, client_email: keychain_ids.first, source: :keychain)
  elsif keychain_ids.length > 1
    raise AmbiguousCredentialsError,
          "Multiple Google Play credentials in Keychain (#{keychain_ids.join(', ')}). " \
          'Pass --play-credentials PATH to disambiguate, or remove the unused ones with ' \
          '`mysigner local-credential delete google_play <client_email>`.'
  end

  # Tier 4: project-sniff (walk up to PROJECT_SNIFF_MAX_DEPTH dirs).
  if (sniffed = sniff_project_for_play(cwd, tried))
    raw, email = read_sa_json!(sniffed, label: 'project-sniff')
    return PlayCreds.new(sa_json: raw, client_email: email, source: :disk)
  end

  # Tier 5: prompt or fail.
  raise CredentialNotFoundError, play_not_found_message(tried: tried) unless stdin.respond_to?(:tty?) && stdin.tty?

  path = prompt(stderr, stdin, 'Path to your Google Play service-account JSON:')
  raw, email = read_sa_json!(File.expand_path(path), label: 'prompt')
  PlayCreds.new(sa_json: raw, client_email: email, source: :prompt)
end