mysigner 0.1.2 → 0.1.4
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/.githooks/pre-commit +15 -0
- data/.githooks/pre-push +21 -0
- data/.github/workflows/ci.yml +29 -0
- data/.gitignore +4 -0
- data/.rubocop.yml +55 -0
- data/.rubocop_todo.yml +126 -0
- data/CHANGELOG.md +96 -0
- data/Gemfile +5 -3
- data/Gemfile.lock +38 -8
- data/README.md +14 -16
- data/Rakefile +5 -3
- data/bin/console +4 -3
- data/bin/setup +3 -0
- data/certificate_.cer +0 -0
- data/exe/mysigner +19 -2
- data/iOS_App_Store_Profile.mobileprovision +1 -0
- data/iOS_Distribution_Certificate.cer +1 -0
- data/lib/mysigner/build/android_executor.rb +83 -63
- data/lib/mysigner/build/android_parser.rb +33 -40
- data/lib/mysigner/build/configurator.rb +17 -16
- data/lib/mysigner/build/detector.rb +39 -50
- data/lib/mysigner/build/error_analyzer.rb +70 -68
- data/lib/mysigner/build/executor.rb +30 -37
- data/lib/mysigner/build/parser.rb +18 -18
- data/lib/mysigner/cleanup/private_keys_purger.rb +41 -0
- data/lib/mysigner/cli/auth_commands.rb +771 -764
- data/lib/mysigner/cli/build_commands.rb +962 -796
- data/lib/mysigner/cli/concerns/actionable_suggestions.rb +208 -154
- data/lib/mysigner/cli/concerns/api_helpers.rb +46 -54
- data/lib/mysigner/cli/concerns/error_handlers.rb +247 -237
- data/lib/mysigner/cli/concerns/helpers.rb +44 -1
- data/lib/mysigner/cli/diagnostic_commands.rb +667 -636
- data/lib/mysigner/cli/resource_commands.rb +1153 -985
- data/lib/mysigner/cli/validate_commands.rb +25 -25
- data/lib/mysigner/cli.rb +11 -1
- data/lib/mysigner/client.rb +27 -19
- data/lib/mysigner/config.rb +161 -60
- data/lib/mysigner/export/exporter.rb +38 -37
- data/lib/mysigner/signing/certificate_checker.rb +18 -23
- data/lib/mysigner/signing/gradle_signing_injector.rb +67 -0
- data/lib/mysigner/signing/keystore_manager.rb +81 -61
- data/lib/mysigner/signing/validator.rb +38 -40
- data/lib/mysigner/signing/wizard.rb +329 -342
- data/lib/mysigner/upload/app_store_automation.rb +96 -49
- data/lib/mysigner/upload/app_store_submission.rb +87 -92
- data/lib/mysigner/upload/asc_rest_uploader.rb +119 -0
- data/lib/mysigner/upload/play_store_uploader.rb +164 -144
- data/lib/mysigner/upload/uploader.rb +136 -115
- data/lib/mysigner/version.rb +3 -1
- data/lib/mysigner.rb +13 -11
- data/mysigner.gemspec +36 -33
- data/profile_.mobileprovision +0 -0
- data/test_manual.rb +37 -36
- metadata +44 -17
- data/.DS_Store +0 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
1
4
|
require 'fileutils'
|
|
2
5
|
require 'tmpdir'
|
|
3
6
|
|
|
@@ -9,31 +12,27 @@ module Mysigner
|
|
|
9
12
|
def initialize(archive_path, output_dir: nil)
|
|
10
13
|
@archive_path = File.expand_path(archive_path)
|
|
11
14
|
@output_dir = output_dir || File.dirname(@archive_path)
|
|
12
|
-
|
|
15
|
+
|
|
13
16
|
validate_archive!
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def export!(method: :appstore, team_id: nil, signing_style: 'automatic')
|
|
17
20
|
say_exporting(method)
|
|
18
|
-
|
|
21
|
+
|
|
19
22
|
# Generate export options plist
|
|
20
23
|
options_plist = generate_export_options(method, team_id, signing_style)
|
|
21
|
-
|
|
24
|
+
|
|
22
25
|
begin
|
|
23
26
|
# Run xcodebuild -exportArchive
|
|
24
27
|
success = execute_export(options_plist)
|
|
25
|
-
|
|
26
|
-
unless success
|
|
27
|
-
|
|
28
|
-
end
|
|
29
|
-
|
|
28
|
+
|
|
29
|
+
raise ExportError, 'Export failed. Check output above for errors.' unless success
|
|
30
|
+
|
|
30
31
|
# Find the generated .ipa file
|
|
31
32
|
ipa_path = find_ipa_file
|
|
32
|
-
|
|
33
|
-
unless ipa_path
|
|
34
|
-
|
|
35
|
-
end
|
|
36
|
-
|
|
33
|
+
|
|
34
|
+
raise ExportError, "Export reported success but .ipa file not found in: #{@output_dir}" unless ipa_path
|
|
35
|
+
|
|
37
36
|
ipa_path
|
|
38
37
|
ensure
|
|
39
38
|
# Clean up temp plist
|
|
@@ -44,42 +43,40 @@ module Mysigner
|
|
|
44
43
|
private
|
|
45
44
|
|
|
46
45
|
def validate_archive!
|
|
47
|
-
unless File.exist?(@archive_path)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
raise ExportError, "Invalid archive: #{@archive_path} (must be a .xcarchive directory)"
|
|
53
|
-
end
|
|
46
|
+
raise ExportError, "Archive not found: #{@archive_path}" unless File.exist?(@archive_path)
|
|
47
|
+
|
|
48
|
+
return if File.directory?(@archive_path) && @archive_path.end_with?('.xcarchive')
|
|
49
|
+
|
|
50
|
+
raise ExportError, "Invalid archive: #{@archive_path} (must be a .xcarchive directory)"
|
|
54
51
|
end
|
|
55
52
|
|
|
56
53
|
def generate_export_options(method, team_id, signing_style)
|
|
57
54
|
require 'plist'
|
|
58
|
-
|
|
55
|
+
|
|
59
56
|
options = {
|
|
60
57
|
'method' => export_method_string(method),
|
|
61
58
|
'uploadBitcode' => false,
|
|
62
59
|
'uploadSymbols' => false,
|
|
63
60
|
'compileBitcode' => false
|
|
64
61
|
}
|
|
65
|
-
|
|
62
|
+
|
|
66
63
|
# Add team ID if provided
|
|
67
64
|
options['teamID'] = team_id if team_id
|
|
68
|
-
|
|
65
|
+
|
|
69
66
|
# Signing style
|
|
70
67
|
if signing_style.to_s.downcase == 'manual'
|
|
71
68
|
options['signingStyle'] = 'manual'
|
|
72
|
-
#
|
|
69
|
+
# NOTE: For manual signing, we'd need to specify provisioningProfiles
|
|
73
70
|
# But since the archive was already signed during build, we can often omit this
|
|
74
71
|
else
|
|
75
72
|
options['signingStyle'] = 'automatic'
|
|
76
73
|
options['signingCertificate'] = 'Apple Distribution'
|
|
77
74
|
end
|
|
78
|
-
|
|
75
|
+
|
|
79
76
|
# Create temp plist file
|
|
80
77
|
plist_path = File.join(Dir.tmpdir, "exportOptions-#{Time.now.to_i}.plist")
|
|
81
78
|
File.write(plist_path, options.to_plist)
|
|
82
|
-
|
|
79
|
+
|
|
83
80
|
plist_path
|
|
84
81
|
end
|
|
85
82
|
|
|
@@ -100,7 +97,7 @@ module Mysigner
|
|
|
100
97
|
|
|
101
98
|
def execute_export(options_plist)
|
|
102
99
|
FileUtils.mkdir_p(@output_dir)
|
|
103
|
-
puts
|
|
100
|
+
puts ''
|
|
104
101
|
|
|
105
102
|
cmd = [
|
|
106
103
|
'xcodebuild',
|
|
@@ -111,40 +108,44 @@ module Mysigner
|
|
|
111
108
|
'-allowProvisioningUpdates' # Allow Xcode to update profiles if needed
|
|
112
109
|
].join(' ')
|
|
113
110
|
|
|
114
|
-
# Run command and capture output
|
|
115
|
-
|
|
111
|
+
# Run command and capture output. xcodebuild output can contain
|
|
112
|
+
# non-ASCII bytes (smart quotes in Apple error messages, emoji in
|
|
113
|
+
# file paths) — force UTF-8 + scrub so `.strip` / `.include?` don't
|
|
114
|
+
# raise Encoding::CompatibilityError under the default US-ASCII
|
|
115
|
+
# locale (e.g. on CI runners without LANG set).
|
|
116
|
+
IO.popen(cmd, err: %i[child out]) do |io|
|
|
116
117
|
io.each_line do |line|
|
|
118
|
+
line = line.force_encoding('UTF-8').scrub
|
|
117
119
|
next if line.strip.empty?
|
|
118
|
-
|
|
120
|
+
|
|
119
121
|
# Show errors and warnings
|
|
120
122
|
if line.include?('error:') || line.include?('warning:')
|
|
121
123
|
puts line
|
|
122
124
|
# Show progress markers
|
|
123
|
-
elsif line.include?('Exporting') || line.include?('Processing') ||
|
|
125
|
+
elsif line.include?('Exporting') || line.include?('Processing') ||
|
|
124
126
|
line.include?('Validating')
|
|
125
127
|
print '.'
|
|
126
128
|
end
|
|
127
129
|
end
|
|
128
130
|
end
|
|
129
131
|
|
|
130
|
-
puts
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
puts '' # New line after dots
|
|
133
|
+
|
|
134
|
+
$CHILD_STATUS.success?
|
|
133
135
|
end
|
|
134
136
|
|
|
135
137
|
def find_ipa_file
|
|
136
138
|
# Look for .ipa files in output directory
|
|
137
139
|
ipa_files = Dir.glob(File.join(@output_dir, '*.ipa'))
|
|
138
|
-
|
|
140
|
+
|
|
139
141
|
# Return the most recently created one
|
|
140
142
|
ipa_files.max_by { |f| File.mtime(f) }
|
|
141
143
|
end
|
|
142
144
|
|
|
143
145
|
def say_exporting(method)
|
|
144
146
|
puts "📦 Exporting archive for #{method}..."
|
|
145
|
-
puts
|
|
147
|
+
puts ''
|
|
146
148
|
end
|
|
147
149
|
end
|
|
148
150
|
end
|
|
149
151
|
end
|
|
150
|
-
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'open3'
|
|
2
4
|
require 'time'
|
|
3
5
|
|
|
@@ -42,23 +44,21 @@ module Mysigner
|
|
|
42
44
|
cmd = 'security find-identity -v -p codesigning'
|
|
43
45
|
stdout, stderr, status = Open3.capture3(cmd)
|
|
44
46
|
|
|
45
|
-
unless status.success?
|
|
46
|
-
raise CheckError, "Failed to query certificates: #{stderr}"
|
|
47
|
-
end
|
|
47
|
+
raise CheckError, "Failed to query certificates: #{stderr}" unless status.success?
|
|
48
48
|
|
|
49
49
|
certificates = []
|
|
50
|
-
|
|
50
|
+
|
|
51
51
|
# Parse output: " 1) HASH \"Certificate Name\""
|
|
52
52
|
stdout.each_line do |line|
|
|
53
53
|
next unless line =~ /\d+\)\s+([A-F0-9]+)\s+"([^"]+)"/
|
|
54
|
-
|
|
55
|
-
hash =
|
|
56
|
-
name =
|
|
57
|
-
|
|
54
|
+
|
|
55
|
+
hash = ::Regexp.last_match(1)
|
|
56
|
+
name = ::Regexp.last_match(2)
|
|
57
|
+
|
|
58
58
|
# Get certificate details
|
|
59
59
|
details = get_certificate_details(name)
|
|
60
60
|
next unless details
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
certificates << {
|
|
63
63
|
hash: hash,
|
|
64
64
|
name: name,
|
|
@@ -76,12 +76,10 @@ module Mysigner
|
|
|
76
76
|
def get_certificate_details(name)
|
|
77
77
|
# Get certificate in PEM format by name
|
|
78
78
|
cmd = "security find-certificate -c \"#{name}\" -p"
|
|
79
|
-
stdout,
|
|
79
|
+
stdout, _, status = Open3.capture3(cmd)
|
|
80
80
|
|
|
81
81
|
# If not found, return nil
|
|
82
|
-
if !status.success? || stdout.empty?
|
|
83
|
-
return nil
|
|
84
|
-
end
|
|
82
|
+
return nil if !status.success? || stdout.empty?
|
|
85
83
|
|
|
86
84
|
# Save to temp file and use openssl to read expiry
|
|
87
85
|
require 'tempfile'
|
|
@@ -92,12 +90,12 @@ module Mysigner
|
|
|
92
90
|
|
|
93
91
|
# Get expiry date using openssl
|
|
94
92
|
cmd = "openssl x509 -in #{temp.path} -noout -enddate"
|
|
95
|
-
out,
|
|
93
|
+
out, _, stat = Open3.capture3(cmd)
|
|
96
94
|
|
|
97
95
|
if stat.success? && out =~ /notAfter=(.+)/
|
|
98
|
-
expiry_str =
|
|
96
|
+
expiry_str = ::Regexp.last_match(1).strip
|
|
99
97
|
expires_at = Time.parse(expiry_str)
|
|
100
|
-
days_until_expiry = ((expires_at - Time.now) /
|
|
98
|
+
days_until_expiry = ((expires_at - Time.now) / 86_400).to_i
|
|
101
99
|
|
|
102
100
|
return {
|
|
103
101
|
expires_at: expires_at,
|
|
@@ -113,11 +111,9 @@ module Mysigner
|
|
|
113
111
|
|
|
114
112
|
def extract_team_id(name)
|
|
115
113
|
# Team ID is usually in parentheses at the end
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
nil
|
|
120
|
-
end
|
|
114
|
+
return unless name =~ /\(([A-Z0-9]{10})\)$/
|
|
115
|
+
|
|
116
|
+
::Regexp.last_match(1)
|
|
121
117
|
end
|
|
122
118
|
|
|
123
119
|
def determine_cert_type(name)
|
|
@@ -134,7 +130,7 @@ module Mysigner
|
|
|
134
130
|
end
|
|
135
131
|
|
|
136
132
|
def determine_status(days_until_expiry)
|
|
137
|
-
if days_until_expiry
|
|
133
|
+
if days_until_expiry.negative?
|
|
138
134
|
STATUS_EXPIRED
|
|
139
135
|
elsif days_until_expiry < 30
|
|
140
136
|
STATUS_EXPIRING_SOON
|
|
@@ -145,4 +141,3 @@ module Mysigner
|
|
|
145
141
|
end
|
|
146
142
|
end
|
|
147
143
|
end
|
|
148
|
-
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
module Mysigner
|
|
7
|
+
module Signing
|
|
8
|
+
class GradleSigningInjector
|
|
9
|
+
# Local variable is `aliasName` (not `keyAlias`) to avoid a Groovy
|
|
10
|
+
# `with { }` scoping ambiguity: `keyAlias = keyAlias` would resolve the
|
|
11
|
+
# RHS against the property being set (null at that point), silently
|
|
12
|
+
# signing with the wrong alias.
|
|
13
|
+
INIT_SCRIPT = <<~GROOVY
|
|
14
|
+
allprojects {
|
|
15
|
+
afterEvaluate { project ->
|
|
16
|
+
if (!project.hasProperty('android')) return
|
|
17
|
+
def storePw = System.getenv('MYSIGNER_STORE_PASSWORD')
|
|
18
|
+
def keyPw = System.getenv('MYSIGNER_KEY_PASSWORD')
|
|
19
|
+
def aliasName = System.getenv('MYSIGNER_KEY_ALIAS')
|
|
20
|
+
def ksPath = System.getenv('MYSIGNER_STORE_FILE')
|
|
21
|
+
if (!storePw || !ksPath) return
|
|
22
|
+
|
|
23
|
+
def existing = project.android.signingConfigs.findByName('release')
|
|
24
|
+
def alreadyConfigured = existing != null && existing.storeFile != null
|
|
25
|
+
if (alreadyConfigured) {
|
|
26
|
+
println "MySigner: release signingConfig already set; skipping override."
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
project.android.signingConfigs.maybeCreate('release').with {
|
|
30
|
+
storeFile = file(ksPath)
|
|
31
|
+
storePassword = storePw
|
|
32
|
+
keyAlias = aliasName
|
|
33
|
+
keyPassword = keyPw
|
|
34
|
+
}
|
|
35
|
+
project.android.buildTypes.findByName('release')?.signingConfig =
|
|
36
|
+
project.android.signingConfigs.getByName('release')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
GROOVY
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
@tmpdir = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write_init_script!
|
|
46
|
+
@tmpdir = Dir.mktmpdir('mysigner-signing-')
|
|
47
|
+
path = File.join(@tmpdir, 'init.gradle')
|
|
48
|
+
File.write(path, INIT_SCRIPT)
|
|
49
|
+
path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def env_vars(keystore_path:, store_password:, key_password:, key_alias:)
|
|
53
|
+
{
|
|
54
|
+
'MYSIGNER_STORE_FILE' => keystore_path,
|
|
55
|
+
'MYSIGNER_STORE_PASSWORD' => store_password,
|
|
56
|
+
'MYSIGNER_KEY_PASSWORD' => key_password,
|
|
57
|
+
'MYSIGNER_KEY_ALIAS' => key_alias
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cleanup!
|
|
62
|
+
FileUtils.rm_rf(@tmpdir) if @tmpdir && Dir.exist?(@tmpdir)
|
|
63
|
+
@tmpdir = nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
1
4
|
require 'fileutils'
|
|
2
5
|
require 'base64'
|
|
6
|
+
require 'open3'
|
|
3
7
|
|
|
4
8
|
module Mysigner
|
|
5
9
|
module Signing
|
|
@@ -18,11 +22,13 @@ module Mysigner
|
|
|
18
22
|
|
|
19
23
|
# List all keystores from API
|
|
20
24
|
# @param android_app_id [Integer, nil] Filter by app ID
|
|
21
|
-
# @param include_secrets [Boolean]
|
|
22
|
-
|
|
25
|
+
# @param include_secrets [Boolean] DEPRECATED and silently ignored. Kept
|
|
26
|
+
# for signature-compat during the 10.0 transition; will be removed in
|
|
27
|
+
# the next release. Passwords are now fetched via #fetch_secrets.
|
|
28
|
+
def list(android_app_id: nil, include_secrets: nil)
|
|
29
|
+
_ = include_secrets # intentionally unused — see note above
|
|
23
30
|
params = {}
|
|
24
31
|
params[:android_app_id] = android_app_id if android_app_id
|
|
25
|
-
params[:include_secrets] = true if include_secrets
|
|
26
32
|
|
|
27
33
|
response = @client.get(
|
|
28
34
|
"/api/v1/organizations/#{@organization_id}/android_keystores",
|
|
@@ -32,33 +38,43 @@ module Mysigner
|
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
# Get active keystore for an app (or any active keystore if no app specified)
|
|
35
|
-
# @param include_secrets [Boolean]
|
|
36
|
-
def active_keystore(android_app_id: nil, include_secrets:
|
|
37
|
-
|
|
41
|
+
# @param include_secrets [Boolean] DEPRECATED — see #list.
|
|
42
|
+
def active_keystore(android_app_id: nil, include_secrets: nil)
|
|
43
|
+
_ = include_secrets
|
|
44
|
+
keystores = list(android_app_id: android_app_id)
|
|
38
45
|
keystores.find { |k| k['active'] }
|
|
39
46
|
end
|
|
40
47
|
|
|
48
|
+
# Phase 0: narrow audit-logged endpoint that returns the keystore
|
|
49
|
+
# password + key password + key alias for a single keystore. Replaces
|
|
50
|
+
# the insecure ?include_secrets=true list flag.
|
|
51
|
+
# Returns a hash: { 'keystore_password' =>, 'key_password' =>, 'key_alias' => }
|
|
52
|
+
def fetch_secrets(keystore_id)
|
|
53
|
+
response = @client.post(
|
|
54
|
+
"/api/v1/organizations/#{@organization_id}/android_keystores/#{keystore_id}/secrets"
|
|
55
|
+
)
|
|
56
|
+
response[:data] || {}
|
|
57
|
+
end
|
|
58
|
+
|
|
41
59
|
# Download a keystore from API and save locally
|
|
42
60
|
# Returns: { path: String, password: String, alias: String, key_password: String }
|
|
43
61
|
def download(keystore_id)
|
|
44
62
|
# Get keystore details
|
|
45
63
|
keystores = list
|
|
46
64
|
keystore = keystores.find { |k| k['id'].to_s == keystore_id.to_s }
|
|
47
|
-
|
|
48
|
-
unless keystore
|
|
49
|
-
raise KeystoreNotFoundError, "Keystore with ID #{keystore_id} not found"
|
|
50
|
-
end
|
|
65
|
+
|
|
66
|
+
raise KeystoreNotFoundError, "Keystore with ID #{keystore_id} not found" unless keystore
|
|
51
67
|
|
|
52
68
|
# Download the keystore file
|
|
53
69
|
download_url = "/api/v1/organizations/#{@organization_id}/android_keystores/#{keystore_id}/download"
|
|
54
|
-
|
|
70
|
+
|
|
55
71
|
conn = build_download_connection
|
|
56
72
|
response = conn.get(download_url)
|
|
57
|
-
|
|
73
|
+
|
|
58
74
|
unless response.success?
|
|
59
75
|
error_msg = begin
|
|
60
76
|
JSON.parse(response.body)['message']
|
|
61
|
-
rescue
|
|
77
|
+
rescue StandardError
|
|
62
78
|
"HTTP #{response.status}"
|
|
63
79
|
end
|
|
64
80
|
raise DownloadError, "Failed to download keystore: #{error_msg}"
|
|
@@ -67,9 +83,9 @@ module Mysigner
|
|
|
67
83
|
# Save to local file
|
|
68
84
|
filename = "#{keystore['name'].gsub(/[^a-zA-Z0-9_.-]/, '_')}.jks"
|
|
69
85
|
local_path = File.join(KEYSTORES_DIR, filename)
|
|
70
|
-
|
|
86
|
+
|
|
71
87
|
File.binwrite(local_path, response.body)
|
|
72
|
-
File.chmod(
|
|
88
|
+
File.chmod(0o600, local_path) # Secure permissions
|
|
73
89
|
|
|
74
90
|
{
|
|
75
91
|
path: local_path,
|
|
@@ -79,40 +95,45 @@ module Mysigner
|
|
|
79
95
|
}
|
|
80
96
|
end
|
|
81
97
|
|
|
82
|
-
# Get or download keystore (uses cached version if available)
|
|
98
|
+
# Get or download keystore (uses cached version if available and fresh).
|
|
99
|
+
# Phase 0: cache has a TTL (default 24h, override via
|
|
100
|
+
# MYSIGNER_KEYSTORE_CACHE_HOURS). Stale files are deleted + re-downloaded.
|
|
83
101
|
def get_or_download(keystore_id)
|
|
84
102
|
keystores = list
|
|
85
103
|
keystore = keystores.find { |k| k['id'].to_s == keystore_id.to_s }
|
|
86
|
-
|
|
87
|
-
unless keystore
|
|
88
|
-
raise KeystoreNotFoundError, "Keystore with ID #{keystore_id} not found"
|
|
89
|
-
end
|
|
90
104
|
|
|
91
|
-
#
|
|
105
|
+
raise KeystoreNotFoundError, "Keystore with ID #{keystore_id} not found" unless keystore
|
|
106
|
+
|
|
92
107
|
filename = "#{keystore['name'].gsub(/[^a-zA-Z0-9_.-]/, '_')}.jks"
|
|
93
108
|
local_path = File.join(KEYSTORES_DIR, filename)
|
|
109
|
+
max_age_hours = (ENV['MYSIGNER_KEYSTORE_CACHE_HOURS'] || 24).to_i
|
|
94
110
|
|
|
95
111
|
if File.exist?(local_path)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
age_seconds = Time.now - File.mtime(local_path)
|
|
113
|
+
if age_seconds < (max_age_hours * 3600)
|
|
114
|
+
return {
|
|
115
|
+
path: local_path,
|
|
116
|
+
name: keystore['name'],
|
|
117
|
+
key_alias: keystore['key_alias'],
|
|
118
|
+
id: keystore['id'],
|
|
119
|
+
cached: true
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
# Stale cache — delete and re-download below
|
|
123
|
+
File.delete(local_path)
|
|
124
|
+
end
|
|
103
125
|
end
|
|
104
126
|
|
|
105
|
-
# Download if not cached
|
|
127
|
+
# Download if not cached (or stale)
|
|
106
128
|
result = download(keystore_id)
|
|
107
129
|
result[:cached] = false
|
|
108
130
|
result
|
|
109
131
|
end
|
|
110
132
|
|
|
111
133
|
# Upload a keystore to API
|
|
112
|
-
def upload(name:, keystore_path:, keystore_password:, key_alias:, key_password: nil, android_app_id: nil,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
end
|
|
134
|
+
def upload(name:, keystore_path:, keystore_password:, key_alias:, key_password: nil, android_app_id: nil,
|
|
135
|
+
active: true)
|
|
136
|
+
raise KeystoreError, "Keystore file not found: #{keystore_path}" unless File.exist?(keystore_path)
|
|
116
137
|
|
|
117
138
|
# Read and encode keystore
|
|
118
139
|
keystore_content = File.binread(keystore_path)
|
|
@@ -141,14 +162,18 @@ module Mysigner
|
|
|
141
162
|
# Delete a keystore
|
|
142
163
|
def delete(keystore_id)
|
|
143
164
|
@client.delete("/api/v1/organizations/#{@organization_id}/android_keystores/#{keystore_id}")
|
|
144
|
-
|
|
165
|
+
|
|
145
166
|
# Also remove local cached file
|
|
146
|
-
keystores =
|
|
167
|
+
keystores = begin
|
|
168
|
+
list
|
|
169
|
+
rescue StandardError
|
|
170
|
+
[]
|
|
171
|
+
end
|
|
147
172
|
keystore = keystores.find { |k| k['id'].to_s == keystore_id.to_s }
|
|
148
173
|
if keystore
|
|
149
174
|
filename = "#{keystore['name'].gsub(/[^a-zA-Z0-9_.-]/, '_')}.jks"
|
|
150
175
|
local_path = File.join(KEYSTORES_DIR, filename)
|
|
151
|
-
|
|
176
|
+
FileUtils.rm_f(local_path)
|
|
152
177
|
end
|
|
153
178
|
|
|
154
179
|
true
|
|
@@ -181,32 +206,32 @@ module Mysigner
|
|
|
181
206
|
end
|
|
182
207
|
end
|
|
183
208
|
|
|
184
|
-
# Get keystore info using keytool
|
|
209
|
+
# Get keystore info using keytool.
|
|
210
|
+
# Phase 0: passes the password via a temp env var consumed by
|
|
211
|
+
# `-storepass:env` so it's never in argv/ps output.
|
|
185
212
|
def keystore_info(keystore_path, password)
|
|
186
213
|
return nil unless File.exist?(keystore_path)
|
|
187
214
|
return nil unless system('which keytool > /dev/null 2>&1')
|
|
188
215
|
|
|
189
|
-
|
|
190
|
-
|
|
216
|
+
env = { 'MYSIGNER_KS_PW' => password.to_s }
|
|
217
|
+
output, status = Open3.capture2e(
|
|
218
|
+
env,
|
|
219
|
+
'keytool', '-list', '-v',
|
|
220
|
+
'-keystore', keystore_path,
|
|
221
|
+
'-storepass:env', 'MYSIGNER_KS_PW'
|
|
222
|
+
)
|
|
223
|
+
return nil unless status.success?
|
|
191
224
|
|
|
192
225
|
# Parse output
|
|
193
226
|
info = {}
|
|
194
|
-
|
|
195
|
-
if output =~ /Alias name: (.+)/
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if output =~ /
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if output =~ /SHA256: (.+)/
|
|
204
|
-
info[:sha256] = $1.strip
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
if output =~ /SHA1: (.+)/
|
|
208
|
-
info[:sha1] = $1.strip
|
|
209
|
-
end
|
|
227
|
+
|
|
228
|
+
info[:aliases] = output.scan(/Alias name: (.+)/).flatten if output =~ /Alias name: (.+)/
|
|
229
|
+
|
|
230
|
+
info[:expires] = ::Regexp.last_match(1) if output =~ /Valid from: .+ until: (.+)/
|
|
231
|
+
|
|
232
|
+
info[:sha256] = ::Regexp.last_match(1).strip if output =~ /SHA256: (.+)/
|
|
233
|
+
|
|
234
|
+
info[:sha1] = ::Regexp.last_match(1).strip if output =~ /SHA1: (.+)/
|
|
210
235
|
|
|
211
236
|
info
|
|
212
237
|
end
|
|
@@ -215,7 +240,7 @@ module Mysigner
|
|
|
215
240
|
|
|
216
241
|
def ensure_keystores_dir
|
|
217
242
|
FileUtils.mkdir_p(KEYSTORES_DIR)
|
|
218
|
-
File.chmod(
|
|
243
|
+
File.chmod(0o700, KEYSTORES_DIR) # Secure permissions
|
|
219
244
|
end
|
|
220
245
|
|
|
221
246
|
def build_download_connection
|
|
@@ -229,11 +254,6 @@ module Mysigner
|
|
|
229
254
|
f.adapter Faraday.default_adapter
|
|
230
255
|
end
|
|
231
256
|
end
|
|
232
|
-
|
|
233
|
-
def shell_escape(str)
|
|
234
|
-
return "''" if str.nil? || str.empty?
|
|
235
|
-
"'" + str.gsub("'", "'\\''") + "'"
|
|
236
|
-
end
|
|
237
257
|
end
|
|
238
258
|
end
|
|
239
259
|
end
|