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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f998d494e2fc03d10bf8efc81bdca8675e5ff6037860a8acf750e66b5f2c2bb
|
|
4
|
+
data.tar.gz: 8c5d7bf17a9cc3556748c6b49106a57ece2f4044e61bfc5c85287e9e7d243d5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3c3929aaca39e935b3d2cb2030320e1955aa24f443d5f17d1bb9f2d996ef99fb10af71dd910fffb5f1c49293a23f90d4071d3055c47e82fb529f27655f29c926
|
|
7
|
+
data.tar.gz: 3c7a4976b73851217a9f1a892c0b6d7a2e43e15ea0678d61bc7edf2d790d634822bd843c2482760b673c692edaa39d12eab41922e5c59d011854c9835493c845
|
data/.gitignore
CHANGED
|
@@ -15,3 +15,28 @@
|
|
|
15
15
|
# macOS
|
|
16
16
|
.DS_Store
|
|
17
17
|
**/.DS_Store
|
|
18
|
+
|
|
19
|
+
# Dotenv / local config
|
|
20
|
+
.env
|
|
21
|
+
.env.*
|
|
22
|
+
*.local
|
|
23
|
+
|
|
24
|
+
# Signing material — must never be committed
|
|
25
|
+
*.p8
|
|
26
|
+
*.p12
|
|
27
|
+
*.pem
|
|
28
|
+
*.key
|
|
29
|
+
*.jks
|
|
30
|
+
*.keystore
|
|
31
|
+
*.cer
|
|
32
|
+
*.mobileprovision
|
|
33
|
+
*.certSigningRequest
|
|
34
|
+
google-service-account*.json
|
|
35
|
+
service-account*.json
|
|
36
|
+
|
|
37
|
+
# Editor / IDE / Ruby LSP
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
/.ruby-lsp/
|
|
41
|
+
*.swp
|
|
42
|
+
*~
|
data/.rubocop_todo.yml
CHANGED
|
@@ -66,6 +66,7 @@ Metrics/BlockNesting:
|
|
|
66
66
|
Metrics/ClassLength:
|
|
67
67
|
Exclude:
|
|
68
68
|
- 'lib/mysigner/signing/wizard.rb'
|
|
69
|
+
- 'lib/mysigner/credential_resolver.rb'
|
|
69
70
|
|
|
70
71
|
# Offense count: 26
|
|
71
72
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
@@ -91,6 +92,7 @@ Metrics/MethodLength:
|
|
|
91
92
|
- 'lib/mysigner/cli/diagnostic_commands.rb'
|
|
92
93
|
- 'lib/mysigner/cli/resource_commands.rb'
|
|
93
94
|
- 'lib/mysigner/cli/validate_commands.rb'
|
|
95
|
+
- 'lib/mysigner/credential_resolver.rb'
|
|
94
96
|
- 'lib/mysigner/signing/wizard.rb'
|
|
95
97
|
- 'lib/mysigner/upload/app_store_automation.rb'
|
|
96
98
|
|
|
@@ -102,14 +104,16 @@ Metrics/ModuleLength:
|
|
|
102
104
|
- 'lib/mysigner/cli/build_commands.rb'
|
|
103
105
|
- 'lib/mysigner/cli/diagnostic_commands.rb'
|
|
104
106
|
- 'lib/mysigner/cli/resource_commands.rb'
|
|
107
|
+
- 'lib/mysigner/credential_resolver.rb'
|
|
105
108
|
|
|
106
|
-
# Offense count:
|
|
109
|
+
# Offense count: 6
|
|
107
110
|
# Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters.
|
|
108
111
|
Metrics/ParameterLists:
|
|
109
112
|
Exclude:
|
|
110
113
|
- 'lib/mysigner/build/executor.rb'
|
|
111
114
|
- 'lib/mysigner/signing/keystore_manager.rb'
|
|
112
115
|
- 'lib/mysigner/upload/asc_rest_uploader.rb'
|
|
116
|
+
- 'lib/mysigner/upload/asc_submitter.rb'
|
|
113
117
|
- 'lib/mysigner/upload/play_store_uploader.rb'
|
|
114
118
|
|
|
115
119
|
# Offense count: 27
|
|
@@ -122,5 +126,6 @@ Metrics/PerceivedComplexity:
|
|
|
122
126
|
- 'lib/mysigner/cli/diagnostic_commands.rb'
|
|
123
127
|
- 'lib/mysigner/cli/resource_commands.rb'
|
|
124
128
|
- 'lib/mysigner/cli/validate_commands.rb'
|
|
129
|
+
- 'lib/mysigner/credential_resolver.rb'
|
|
125
130
|
- 'lib/mysigner/signing/wizard.rb'
|
|
126
131
|
- 'lib/mysigner/upload/app_store_automation.rb'
|
data/CHANGELOG.md
CHANGED
|
@@ -94,3 +94,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
94
94
|
- Progress spinners (TTY::Spinner)
|
|
95
95
|
- CI/CD templates for GitHub Actions and GitLab CI
|
|
96
96
|
- Phased release support for App Store
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## [0.2.0] - 2026-05-26
|
|
101
|
+
|
|
102
|
+
### Added — `--local-only` mode
|
|
103
|
+
|
|
104
|
+
Brand-new opt-in mode that lets you ship to TestFlight / Play Store
|
|
105
|
+
without sending any signing credentials to the MySigner server.
|
|
106
|
+
Activate via the `--local-only` flag on any command or by setting
|
|
107
|
+
`MYSIGNER_LOCAL_ONLY=1`. Proven end-to-end against a real iOS app
|
|
108
|
+
(real TestFlight upload).
|
|
109
|
+
|
|
110
|
+
- **Credential auto-discovery cascade** (`Mysigner::CredentialResolver`):
|
|
111
|
+
walks per-command flags → env vars (`APP_STORE_CONNECT_API_KEY_*`,
|
|
112
|
+
`GOOGLE_APPLICATION_CREDENTIALS`, `MYSIGNER_KEYSTORE_*` /
|
|
113
|
+
`ANDROID_KEYSTORE_*`) → macOS Keychain (`Mysigner::LocalCredentials`)
|
|
114
|
+
→ standard tool locations (`~/.appstoreconnect/private_keys/AuthKey_*.p8`,
|
|
115
|
+
`eas.json`, `android/key.properties`, `android/app/build.gradle[.kts]`
|
|
116
|
+
inline `signingConfigs`, `~/.gradle/gradle.properties`) → interactive
|
|
117
|
+
prompt (TTY-gated; non-TTY fails loud with the exact override knob to
|
|
118
|
+
set).
|
|
119
|
+
- **iOS local-only ship** (`mysigner --local-only ship appstore`):
|
|
120
|
+
bypasses MySigner auth bootstrap entirely (no login required). Mints
|
|
121
|
+
ASC JWT locally and shells out to `xcrun altool --upload-app` (Apple's
|
|
122
|
+
canonical CLI). Submit-for-review automated via the modern 3-step
|
|
123
|
+
`/v1/reviewSubmissions` choreography.
|
|
124
|
+
- **Android local-only ship** (`mysigner --local-only ship play`): mints
|
|
125
|
+
Google OAuth2 access token locally from the discovered SA-JSON.
|
|
126
|
+
Pre-checks Play's highest existing `versionCode` and exits with a
|
|
127
|
+
"bump versionCode to N+1" hint before wasting an upload Google would
|
|
128
|
+
reject. Bypasses every MySigner server endpoint that previously ran
|
|
129
|
+
on the Android path (keystore download, build records, etc.).
|
|
130
|
+
- **`mysigner onboard --local-only`**: walks the user through local
|
|
131
|
+
credential setup interactively; skips the per-platform prompt when
|
|
132
|
+
credentials are already discoverable via the cascade.
|
|
133
|
+
|
|
134
|
+
### Added — Security & hygiene
|
|
135
|
+
|
|
136
|
+
- **`mysigner logout --purge`**: optionally hard-deletes stored
|
|
137
|
+
credentials on the MySigner server AND wipes local Keychain entries.
|
|
138
|
+
Default behavior prompts (TTY-only; non-TTY defaults to No). New
|
|
139
|
+
`--no-purge` flag opts out without prompting.
|
|
140
|
+
- Two new global flags: `--local-only` and `--auto-submit` /
|
|
141
|
+
`--no-auto-submit`.
|
|
142
|
+
- New per-command flags on `ship`: `--asc-key-path`, `--asc-key-id`,
|
|
143
|
+
`--asc-issuer-id`, `--apple-id`, `--play-credentials`,
|
|
144
|
+
`--keystore-path`, `--keystore-password`, `--key-alias`,
|
|
145
|
+
`--key-password`.
|
|
146
|
+
|
|
147
|
+
### Changed
|
|
148
|
+
|
|
149
|
+
- "Not logged in" error now also suggests `--local-only` as an
|
|
150
|
+
alternative for users who don't want a MySigner account.
|
|
151
|
+
- `Signing::Validator`'s no-team error message no longer suggests "Add
|
|
152
|
+
team to My Signer" when in `--local-only` mode.
|
|
153
|
+
- Multiple Apple `appStoreState` values handled with actionable typed
|
|
154
|
+
errors during submit-for-review (`VersionInFlightError`,
|
|
155
|
+
`VersionAlreadyReleasedError`, `SubmissionRejectedError`,
|
|
156
|
+
`BuildProcessingTimeoutError`, `AppleApiError`).
|
|
157
|
+
- Submit-for-review poll loop is resilient to transient errors and
|
|
158
|
+
respects HTTP 429 `Retry-After`.
|
|
159
|
+
- `Config#load` uses `YAML.safe_load_file` instead of `YAML.load_file`
|
|
160
|
+
— rejects `!ruby/object:` directives loud.
|
|
161
|
+
|
|
162
|
+
### Removed (breaking)
|
|
163
|
+
|
|
164
|
+
- **`MYSIGNER_USE_LEGACY_ASC` env var + the legacy altool path it
|
|
165
|
+
gated**. The modern envelope-encryption path (vault mode) and the new
|
|
166
|
+
`--local-only` mode supersede it. Users relying on this env var will
|
|
167
|
+
need to migrate.
|
|
168
|
+
- `Mysigner::Signing::KeystoreManager#list` / `#active_keystore` no
|
|
169
|
+
longer accept the deprecated `include_secrets:` keyword (fetch
|
|
170
|
+
passwords via `#fetch_secrets` instead — already the modern path).
|
|
171
|
+
- Test certs / mobileprovision files removed from the gem bundle.
|
|
172
|
+
|
|
173
|
+
### Fixed
|
|
174
|
+
|
|
175
|
+
- Thor parser bug where `--local-only` (or any class_option) placed
|
|
176
|
+
before the subcommand was eaten by Thor's command-name lookup,
|
|
177
|
+
silently routing to `help`. The `exe/mysigner` entry point now hoists
|
|
178
|
+
leading class_options past the subcommand word.
|
|
179
|
+
- The previous iOS REST upload reinvented Apple's `/v1/buildUploads`
|
|
180
|
+
payload shape and got it wrong (Apple rejected with
|
|
181
|
+
`ENTITY_ERROR.ATTRIBUTE.UNKNOWN` on `fileName` / `fileSize`); the
|
|
182
|
+
resulting 409 handler silently mapped every 409 to "build version
|
|
183
|
+
conflict" and masked the real error. Replaced with `xcrun altool`
|
|
184
|
+
shell-out (Apple's canonical CLI).
|
|
185
|
+
- README's "Secure" bullet no longer says "credentials stored locally"
|
|
186
|
+
— was misleading in default vault mode. New copy names the AES-256
|
|
187
|
+
at-rest server encryption and points at the `--local-only` opt-in
|
|
188
|
+
that delivers the literal property.
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
mysigner (0.
|
|
4
|
+
mysigner (0.2.0)
|
|
5
5
|
base64 (~> 0.2)
|
|
6
6
|
faraday (~> 2.14)
|
|
7
7
|
faraday-retry (~> 2.2)
|
|
@@ -19,8 +19,8 @@ GEM
|
|
|
19
19
|
base64
|
|
20
20
|
nkf
|
|
21
21
|
rexml
|
|
22
|
-
addressable (2.
|
|
23
|
-
public_suffix (>= 2.0.2, <
|
|
22
|
+
addressable (2.9.0)
|
|
23
|
+
public_suffix (>= 2.0.2, < 8.0)
|
|
24
24
|
ast (2.4.3)
|
|
25
25
|
atomos (0.1.3)
|
|
26
26
|
base64 (0.3.0)
|
|
@@ -32,7 +32,7 @@ GEM
|
|
|
32
32
|
rexml
|
|
33
33
|
declarative (0.0.20)
|
|
34
34
|
diff-lcs (1.6.2)
|
|
35
|
-
faraday (2.14.
|
|
35
|
+
faraday (2.14.2)
|
|
36
36
|
faraday-net_http (>= 2.0, < 3.5)
|
|
37
37
|
json
|
|
38
38
|
logger
|
|
@@ -66,8 +66,8 @@ GEM
|
|
|
66
66
|
signet (>= 0.16, < 2.a)
|
|
67
67
|
hashdiff (1.2.1)
|
|
68
68
|
io-console (0.8.1)
|
|
69
|
-
json (2.19.
|
|
70
|
-
jwt (3.
|
|
69
|
+
json (2.19.5)
|
|
70
|
+
jwt (3.2.0)
|
|
71
71
|
base64
|
|
72
72
|
language_server-protocol (3.17.0.5)
|
|
73
73
|
lint_roller (1.1.0)
|
|
@@ -85,7 +85,7 @@ GEM
|
|
|
85
85
|
racc
|
|
86
86
|
plist (3.7.2)
|
|
87
87
|
prism (1.9.0)
|
|
88
|
-
public_suffix (
|
|
88
|
+
public_suffix (7.0.5)
|
|
89
89
|
racc (1.8.1)
|
|
90
90
|
rainbow (3.1.1)
|
|
91
91
|
rake (13.3.0)
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Mobile developers spend hours dealing with:
|
|
|
27
27
|
✅ **CI/CD Ready** - Automate builds in GitHub Actions, GitLab CI, etc.
|
|
28
28
|
✅ **API-Powered** - Backed by My Signer API for team collaboration
|
|
29
29
|
✅ **Smart Version Handling** - Auto-increment version codes for Android
|
|
30
|
-
✅ **Secure** - Token-based
|
|
30
|
+
✅ **Secure** - Token-based auth; signing credentials are AES-256 encrypted at rest on the MySigner server by default, with opt-in `--local-only` mode that keeps Apple `.p8` and Google service-account JSON on your machine
|
|
31
31
|
|
|
32
32
|
---
|
|
33
33
|
|
|
@@ -316,6 +316,99 @@ mysigner signing configure --all-targets # Configure all targets
|
|
|
316
316
|
|
|
317
317
|
---
|
|
318
318
|
|
|
319
|
+
## Local-only mode (`--local-only` / `MYSIGNER_LOCAL_ONLY`)
|
|
320
|
+
|
|
321
|
+
Local-only mode routes signing-credential auth through your machine instead of the My Signer server. Your Apple `.p8` private key and Google Play service-account JSON live in the macOS Keychain (or an AES-256-GCM-encrypted file on Linux/Windows) and the CLI mints the ASC JWT and Google OAuth token locally at upload time. Activate per command with `--local-only` or globally with `MYSIGNER_LOCAL_ONLY=1`.
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
mysigner --local-only ship appstore
|
|
325
|
+
mysigner --local-only ship play production
|
|
326
|
+
MYSIGNER_LOCAL_ONLY=1 mysigner ship testflight
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Local-only vs vault (server) mode
|
|
330
|
+
|
|
331
|
+
| | Vault (default) | Local-only |
|
|
332
|
+
| ------------------------------- | -------------------------------------- | ------------------------------------------------ |
|
|
333
|
+
| Holds `.p8` / SA-JSON at rest | My Signer server (CMK-encrypted) | Your macOS Keychain or local AES-256-GCM file |
|
|
334
|
+
| Mints ASC JWT / Google OAuth | Server | CLI, on your machine |
|
|
335
|
+
| Server endpoints used at ship | All (sync, list, builds, submit, etc.) | All except credential-minting and the actual API upload |
|
|
336
|
+
| Multi-machine sync | Yes — log in from anywhere | No — re-onboard on each machine |
|
|
337
|
+
| Team-sharing | Yes — server gates per-user access | No — per-machine only |
|
|
338
|
+
| CI/CD setup | API token in CI secrets | Pre-populate `~/.mysigner/credentials/` from a secret store |
|
|
339
|
+
| Revocation surface | Revoke at server (audit log) | Wipe the Keychain entry / local file |
|
|
340
|
+
|
|
341
|
+
### What local-only does NOT do (v1)
|
|
342
|
+
|
|
343
|
+
Local-only mode in v1 guards the **API-call-time credentials** (the ASC JWT and the Google OAuth token). The `ship` pipelines still talk to My Signer for orchestration. Be honest with yourself about which of these matter for your threat model:
|
|
344
|
+
|
|
345
|
+
- **`ship appstore --local-only`** still calls My Signer for pre-upload sync, app/build lookup, the post-upload poll loop, and `submit_for_review!`. Only the upload itself goes direct to Apple.
|
|
346
|
+
- **`ship play --local-only`** still calls My Signer for the Android build record, `link_to_app`, release defaults, highest-version-code lookup, **and the keystore download** (via the keystore manager). Only the Play Publishing API call goes direct to Google.
|
|
347
|
+
- **Android keystore is the biggest gap.** The signing keystore is still downloaded from My Signer in local-only mode. v1 does not guard the long-lived Android signing material itself — only the OAuth token used to talk to Google.
|
|
348
|
+
- **Single-account assumption.** Both `ship appstore` and `ship play` pick the first credential in storage order (`LocalCredentials.list.first`). If you have multiple ASC accounts or multiple Google Play SA-JSONs, there is no way to choose between them yet. Multi-account routing (`--asc-account`, `--google-play-account`) is a planned follow-up.
|
|
349
|
+
- **CI/CD non-interactive onboarding.** `mysigner onboard --local-only` is interactive only. For CI you must pre-populate `~/.mysigner/credentials/` from a secret store before invoking the CLI; a non-interactive onboarding mode is a planned follow-up.
|
|
350
|
+
|
|
351
|
+
### Setup
|
|
352
|
+
|
|
353
|
+
Run the guided flow:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
mysigner --local-only onboard
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
You'll be asked whether to set up App Store Connect, Google Play, or both:
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
🚀 My Signer Setup (local-only)
|
|
363
|
+
================================================================================
|
|
364
|
+
|
|
365
|
+
Local-only mode: credentials stay on this machine.
|
|
366
|
+
|
|
367
|
+
Set up App Store Connect credentials now? [Y/n]
|
|
368
|
+
|
|
369
|
+
📱 App Store Connect (local-only)
|
|
370
|
+
|
|
371
|
+
Path to your .p8 private key: # e.g. ~/Downloads/AuthKey_ABC12345.p8
|
|
372
|
+
Enter your Key ID (e.g., ABC12345): # auto-detected from filename when possible
|
|
373
|
+
Enter your Issuer ID (UUID):
|
|
374
|
+
|
|
375
|
+
Set up Google Play credentials now? [Y/n]
|
|
376
|
+
|
|
377
|
+
🤖 Google Play (local-only)
|
|
378
|
+
|
|
379
|
+
Path to your service-account JSON: # e.g. ~/Downloads/sa-key.json
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Storage:
|
|
383
|
+
- **macOS**: Keychain service `com.mysigner.cli.credentials`, accounts namespaced as `asc:<key_id>` and `google_play:<client_email>`.
|
|
384
|
+
- **Other OSes**: per-credential AES-256-GCM files under `~/.mysigner/credentials/<kind>/<id>`, mode `0600`, encrypted under the same per-machine key as `Config`.
|
|
385
|
+
|
|
386
|
+
### Daily usage
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
# iOS — local-mint ASC JWT, upload direct to Apple
|
|
390
|
+
mysigner --local-only ship testflight
|
|
391
|
+
mysigner --local-only ship appstore --submit-for-review
|
|
392
|
+
|
|
393
|
+
# Android — local-mint Google OAuth token, call Play Publishing API direct
|
|
394
|
+
mysigner --local-only ship internal --platform android
|
|
395
|
+
mysigner --local-only ship production --platform android
|
|
396
|
+
|
|
397
|
+
# Or set globally for the shell / CI job
|
|
398
|
+
export MYSIGNER_LOCAL_ONLY=1
|
|
399
|
+
mysigner ship testflight
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Threat model
|
|
403
|
+
|
|
404
|
+
In vault mode, My Signer's CMK / envelope encryption gates access to your `.p8` and SA-JSON at rest on the server, and access is logged and revocable at the org level. In local-only mode the same material sits in your macOS Keychain (or AES-256-GCM file) gated by your machine's user account. The tradeoff is **who you trust**: My Signer's infrastructure vs your own machine. Single-developer teams, regulated industries, and security-paranoid setups generally prefer local-only; collaborative teams generally prefer vault mode for its revocation surface and shared visibility.
|
|
405
|
+
|
|
406
|
+
### Migration
|
|
407
|
+
|
|
408
|
+
To switch from vault to local-only, run `mysigner --local-only onboard` and re-enter your credentials. The server-stored credentials stay in My Signer but the CLI stops fetching them while `--local-only` / `MYSIGNER_LOCAL_ONLY` is set. To switch back, run `mysigner onboard` (no flag). There is no automated data migration — credentials are re-entered on the new side.
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
319
412
|
## Configuration
|
|
320
413
|
|
|
321
414
|
My Signer CLI stores configuration in `~/.mysigner/config.yml`:
|
data/exe/mysigner
CHANGED
|
@@ -3,6 +3,60 @@
|
|
|
3
3
|
|
|
4
4
|
require 'mysigner'
|
|
5
5
|
|
|
6
|
+
# Hoist class_options written BEFORE the subcommand to AFTER it.
|
|
7
|
+
# Thor's `retrieve_command_name` only treats the first non-flag arg as the
|
|
8
|
+
# command — when ARGV starts with a class_option flag like `--local-only`,
|
|
9
|
+
# Thor sees no command, silently dispatches to `help`, and we get
|
|
10
|
+
# `"mysigner help" was called with arguments ["ship", "appstore", ...]`.
|
|
11
|
+
#
|
|
12
|
+
# We avoid hard-coding the flag list by reading
|
|
13
|
+
# `Mysigner::CLI.class_options` at startup. Class-option booleans support
|
|
14
|
+
# Thor's auto-generated `--no-flag` / `--skip-flag` variants too. String/
|
|
15
|
+
# numeric options aren't currently used as class_options here, but if one
|
|
16
|
+
# is added we also recognise `--flag value` (separate-arg form).
|
|
17
|
+
def hoist_leading_class_options!(argv)
|
|
18
|
+
return argv if argv.empty?
|
|
19
|
+
|
|
20
|
+
class_opts = Mysigner::CLI.class_options
|
|
21
|
+
switches = class_opts.each_with_object({}) do |(_, opt), acc|
|
|
22
|
+
Array(opt.switch_name).each { |s| acc[s] = opt }
|
|
23
|
+
opt.aliases.each { |s| acc[s] = opt }
|
|
24
|
+
next unless opt.type == :boolean && opt.switch_name.start_with?('--')
|
|
25
|
+
|
|
26
|
+
base = opt.switch_name.sub(/\A--/, '')
|
|
27
|
+
acc["--no-#{base}"] = opt
|
|
28
|
+
acc["--skip-#{base}"] = opt
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
hoisted = []
|
|
32
|
+
i = 0
|
|
33
|
+
while i < argv.length
|
|
34
|
+
arg = argv[i]
|
|
35
|
+
flag_name = arg.split('=', 2).first
|
|
36
|
+
break unless switches.key?(flag_name)
|
|
37
|
+
|
|
38
|
+
hoisted << arg
|
|
39
|
+
# `--flag value` form: pull the value too, but only when the flag was
|
|
40
|
+
# written WITHOUT `=` AND the option isn't a boolean (booleans don't
|
|
41
|
+
# take a value).
|
|
42
|
+
opt = switches[flag_name]
|
|
43
|
+
if opt.type != :boolean && !arg.include?('=') && argv[i + 1] && !argv[i + 1].start_with?('-')
|
|
44
|
+
hoisted << argv[i + 1]
|
|
45
|
+
i += 1
|
|
46
|
+
end
|
|
47
|
+
i += 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return argv if hoisted.empty?
|
|
51
|
+
# No command word follows the leading flags — leave argv alone so Thor
|
|
52
|
+
# can fall through to its own no-command help dispatch.
|
|
53
|
+
return argv if i >= argv.length
|
|
54
|
+
|
|
55
|
+
command = argv[i]
|
|
56
|
+
rest = argv[(i + 1)..]
|
|
57
|
+
[command, *hoisted, *rest]
|
|
58
|
+
end
|
|
59
|
+
|
|
6
60
|
# Normalize `mysigner <cmd> [args…] --help` → `mysigner help <cmd>`. Thor
|
|
7
61
|
# treats extra positional args as a fatal argument-count error, so without
|
|
8
62
|
# this rewrite users who type `mysigner ship testflight --help` see
|
|
@@ -19,4 +73,4 @@ def rewrite_help_flag!(argv)
|
|
|
19
73
|
['help', first]
|
|
20
74
|
end
|
|
21
75
|
|
|
22
|
-
Mysigner::CLI.start(rewrite_help_flag!(ARGV))
|
|
76
|
+
Mysigner::CLI.start(rewrite_help_flag!(hoist_leading_class_options!(ARGV)))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module Mysigner
|
|
7
|
+
module Auth
|
|
8
|
+
# Mints an Apple App Store Connect API JWT locally from a .p8 private key.
|
|
9
|
+
#
|
|
10
|
+
# Used by the CLI's local-only mode (epic mysigner-22) so credentials never
|
|
11
|
+
# leave the user's machine. The MySigner server path mints the same shape
|
|
12
|
+
# server-side; this class produces a token equivalent to what the server
|
|
13
|
+
# would return, so downstream ASC callers (e.g. Mysigner::Upload::AscRestUploader)
|
|
14
|
+
# are agnostic to where the token came from.
|
|
15
|
+
#
|
|
16
|
+
# Apple's spec (https://developer.apple.com/documentation/appstoreconnectapi/
|
|
17
|
+
# generating-tokens-for-api-requests):
|
|
18
|
+
# - header: { alg: ES256, kid: <key_id>, typ: JWT }
|
|
19
|
+
# - claims: iss=<issuer_id>, iat=<now>, exp=<iat+TTL>, aud=appstoreconnect-v1
|
|
20
|
+
# - signed ES256 over base64url(header).base64url(claims) with the .p8 EC key
|
|
21
|
+
#
|
|
22
|
+
# Apple caps token lifetime at 20 minutes. We default to 19 minutes so
|
|
23
|
+
# consumers can safely reuse a token throughout a typical ASC upload session
|
|
24
|
+
# without bumping into the cap mid-request.
|
|
25
|
+
class AscJwtMinter
|
|
26
|
+
DEFAULT_TTL = 19 * 60
|
|
27
|
+
AUDIENCE = 'appstoreconnect-v1'
|
|
28
|
+
|
|
29
|
+
def initialize(key_id:, issuer_id:, p8_pem:)
|
|
30
|
+
@key_id = present!(key_id, 'key_id')
|
|
31
|
+
@issuer_id = present!(issuer_id, 'issuer_id')
|
|
32
|
+
@ec_key = parse_ec_key(present!(p8_pem, 'p8_pem'))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns a String JWT. `now` is injectable for testability.
|
|
36
|
+
def mint(ttl: DEFAULT_TTL, now: Time.now)
|
|
37
|
+
iat = now.to_i
|
|
38
|
+
payload = {
|
|
39
|
+
iss: @issuer_id,
|
|
40
|
+
iat: iat,
|
|
41
|
+
exp: iat + ttl,
|
|
42
|
+
aud: AUDIENCE
|
|
43
|
+
}
|
|
44
|
+
headers = { kid: @key_id, typ: 'JWT' }
|
|
45
|
+
# JWT.encode with ES256 produces a fixed-width raw r||s signature per
|
|
46
|
+
# JWA RFC 7518, which is what Apple requires.
|
|
47
|
+
JWT.encode(payload, @ec_key, 'ES256', headers)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def present!(value, name)
|
|
53
|
+
raise ArgumentError, "#{name} must be present" if value.nil? || value.to_s.empty?
|
|
54
|
+
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_ec_key(p8_pem)
|
|
59
|
+
key = OpenSSL::PKey.read(p8_pem.to_s)
|
|
60
|
+
raise ArgumentError, "p8_pem must be an EC private key (got #{key.class})" unless key.is_a?(OpenSSL::PKey::EC)
|
|
61
|
+
|
|
62
|
+
key
|
|
63
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
64
|
+
raise ArgumentError, "p8_pem could not be parsed as a private key: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'googleauth'
|
|
6
|
+
|
|
7
|
+
module Mysigner
|
|
8
|
+
module Auth
|
|
9
|
+
# Mints a short-lived Google OAuth2 access_token from a service-account
|
|
10
|
+
# JSON key, locally — without going through the MySigner server. The
|
|
11
|
+
# service-account JSON never leaves the caller's process.
|
|
12
|
+
#
|
|
13
|
+
# Delegates the JWT mint + assertion exchange + caching to googleauth
|
|
14
|
+
# (Google::Auth::ServiceAccountCredentials), which is already a runtime
|
|
15
|
+
# dependency for the Play Publishing API client.
|
|
16
|
+
class GoogleOauthMinter
|
|
17
|
+
DEFAULT_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'
|
|
18
|
+
REQUIRED_KEYS = %w[type client_email private_key project_id].freeze
|
|
19
|
+
|
|
20
|
+
# @param service_account_json [String, Hash] the raw JSON string or an
|
|
21
|
+
# already-parsed Hash. Validated for required keys.
|
|
22
|
+
# @raise [ArgumentError] when input is nil/empty, unparseable, or missing
|
|
23
|
+
# any of {REQUIRED_KEYS}.
|
|
24
|
+
def initialize(service_account_json)
|
|
25
|
+
@json_hash = coerce_to_hash(service_account_json)
|
|
26
|
+
validate_required_keys!(@json_hash)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param scope [String] OAuth2 scope. Defaults to the Play Publishing
|
|
30
|
+
# scope used by `ship play`. Override for other Google APIs.
|
|
31
|
+
# @return [String] a non-empty access_token (e.g. "ya29...").
|
|
32
|
+
def mint(scope: DEFAULT_SCOPE)
|
|
33
|
+
token_data = fetch_token(scope)
|
|
34
|
+
token_data['access_token'] || token_data[:access_token]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Variant that returns both the token and its expiry timestamp, for
|
|
38
|
+
# callers that want to manage caching themselves.
|
|
39
|
+
# @return [TokenWithExpiry] members :access_token, :expires_at (Time or nil)
|
|
40
|
+
def mint_with_expiry(scope: DEFAULT_SCOPE)
|
|
41
|
+
token_data = fetch_token(scope)
|
|
42
|
+
access_token = token_data['access_token'] || token_data[:access_token]
|
|
43
|
+
expires_in = token_data['expires_in'] || token_data[:expires_in]
|
|
44
|
+
expires_at = expires_in ? Time.now + expires_in.to_i : nil
|
|
45
|
+
TokenWithExpiry.new(access_token, expires_at)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
TokenWithExpiry = Struct.new(:access_token, :expires_at)
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def fetch_token(scope)
|
|
53
|
+
creds = Google::Auth::ServiceAccountCredentials.make_creds(
|
|
54
|
+
json_key_io: StringIO.new(JSON.dump(@json_hash)),
|
|
55
|
+
scope: scope
|
|
56
|
+
)
|
|
57
|
+
creds.fetch_access_token!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def coerce_to_hash(input)
|
|
61
|
+
raise ArgumentError, 'service_account_json is required' if input.nil?
|
|
62
|
+
|
|
63
|
+
case input
|
|
64
|
+
when Hash
|
|
65
|
+
raise ArgumentError, 'service_account_json hash is empty' if input.empty?
|
|
66
|
+
|
|
67
|
+
input.transform_keys(&:to_s)
|
|
68
|
+
when String
|
|
69
|
+
raise ArgumentError, 'service_account_json is required' if input.strip.empty?
|
|
70
|
+
|
|
71
|
+
begin
|
|
72
|
+
JSON.parse(input)
|
|
73
|
+
rescue JSON::ParserError => e
|
|
74
|
+
raise ArgumentError, "service_account_json is not valid JSON: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
raise ArgumentError, "service_account_json must be a String or Hash, got #{input.class}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_required_keys!(hash)
|
|
82
|
+
missing = REQUIRED_KEYS.reject { |key| hash[key].is_a?(String) && !hash[key].strip.empty? }
|
|
83
|
+
return if missing.empty?
|
|
84
|
+
|
|
85
|
+
raise ArgumentError, "service_account_json is missing required keys: #{missing.join(', ')}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|