Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support scheme v3.1 #122

Merged
merged 10 commits into from
Nov 2, 2023
113 changes: 65 additions & 48 deletions lib/android_apk.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
require "tmpdir"
require "zip"

require_relative "android_apk/apksigner"
require_relative "android_apk/app_icon"
require_relative "android_apk/app_signature"
require_relative "android_apk/error"
require_relative "android_apk/resource_finder"
require_relative "android_apk/signature_digest"
require_relative "android_apk/signature_lineage_reader"
require_relative "android_apk/signature_verifier"
require_relative "android_apk/xmltree"

Expand Down Expand Up @@ -67,9 +71,9 @@ class AndroidApk
# @return [String] Return Integer string which is defined in AndroidManifest.xml
attr_accessor :target_sdk_version

# The trusted signature lineage. The first element is the same to the signing signature of the apk file.
# @return [Array<String>] empty if it's unsigned.
attr_reader :trusted_signature_lineage
# An object contains lineages and certificate fingerprints
# @return [AndroidApk::AppSignature] a signature representation
attr_reader :app_signature
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exposes v1, v2, v3, v3.1 compatible interfaces.


# Check whether or not this apk is a test mode
# @return [Boolean] Return true if this apk is a test mode, otherwise false.
Expand All @@ -81,21 +85,6 @@ class AndroidApk
# @return [String] Return a file path of this apk file
attr_accessor :filepath

# The SHA-1 signature of this apk
# @deprecated
# @return [String, nil] Return nil if cannot extract sha1 hash, otherwise the value will be returned.
def signature
trusted_signature_lineage[0]
end

# Check whether or not this apk is verified
# @deprecated this is the same to unsigned i.e. signature is nil
# @return [Boolean] Return true if this apk is verified, otherwise false.
def verified
!trusted_signature_lineage.empty?
end
alias verified? verified

NOT_ALLOW_DUPLICATE_TAG_NAMES = %w(
application
sdkVersion
Expand Down Expand Up @@ -192,11 +181,8 @@ def self.analyze(filepath)
end.merge(icons_in_arsc)

apk.instance_variable_set(
:@trusted_signature_lineage,
::AndroidApk::SignatureVerifier.verify(
filepath: filepath,
target_sdk_version: apk.target_sdk_version
)
:@app_signature,
AppSignature.parse(filepath: filepath, min_sdk_version: apk.min_sdk_version)
)

return apk
Expand Down Expand Up @@ -310,31 +296,6 @@ def dpi_str(dpi)
DPI_TO_NAME_MAP[dpi.to_i] || "xxxhdpi"
end

# Experimental API!
# Check whether or not this apk is installable
# @return [Boolean] Return true if this apk is installable, otherwise false.
def installable?
uninstallable_reasons.empty?
end

# Experimental API!
# Reasons why this apk is not installable
# @return [Array<Symbol>] Return non-empty symbol array which contain reasons, otherwise an empty array.
def uninstallable_reasons
reasons = []
reasons << Reason::UNVERIFIED unless verified?
reasons << Reason::UNSIGNED unless signed?
reasons << Reason::TEST_ONLY if test_only?
reasons
end

# Whether or not this apk is signed but this depends on (see signature)
#
# @return [Boolean, nil] this apk is signed if true, otherwise not signed.
def signed?
!signature.nil?
end

def adaptive_icon_density
min_sdk_version.to_i >= ADAPTIVE_ICON_SDK ? "anydpi" : "anydpi-v26"
end
Expand Down Expand Up @@ -363,6 +324,62 @@ def backward_compatible_adaptive_icon?
@backward_compatible_adaptive_icon = icon_xmltree&.adaptive_icon? && SUPPORTED_DPI_NAMES.any? { |d| icon_path_hash[d]&.end_with?(".png") }
end

# deprecations

# The trusted signature lineage. The first element is the same to the signing signature of the apk file.
# @return [Array<String>] empty if it's unsigned.
def trusted_signature_lineage
return [] if app_signature.unsigned?

if app_signature.lineages.empty?
[signature]
else
app_signature.lineages.map { |l| l[SignatureDigest::SHA1] }.reverse
end
end

# The SHA-1 signature of this apk
# @deprecated single signature is not applicable since scheme v3
# @return [String, nil] Return nil if cannot extract sha1 hash, otherwise the value will be returned.
def signature
v = app_signature.get_fingerprint(sdk_version: target_sdk_version.to_i)
v && v[SignatureDigest::SHA1]
end

# Check whether or not this apk is verified
# @deprecated single signature is not applicable since scheme v3
# @return [Boolean] Return true if this apk is verified, otherwise false.
def verified
!signature.nil?
end

alias verified? verified

# Whether or not this apk is signed but this depends on (see signature)
# @deprecated single signature is not applicable since scheme v3
# @return [Boolean, nil] this apk is signed if true, otherwise not signed.
def signed?
signature != nil
end

# @deprecated this value contains true-negative since scheme v3.1
# @return [Boolean] Return true if this apk is installable, otherwise false.
def installable?
uninstallable_reasons.empty?
end

# @deprecated this value contains true-negative since scheme v3.1
# @return [Array<Symbol>] Return non-empty symbol array which contain reasons, otherwise an empty array.
def uninstallable_reasons
reasons = []
reasons << Reason::UNVERIFIED unless verified?
reasons << Reason::UNSIGNED unless signed?
reasons << Reason::TEST_ONLY if test_only?
reasons
end

# end: deprecations

# workaround for https://code.google.com/p/android/issues/detail?id=160847
def self._parse_values_workaround(str)
return nil if str.nil?
Expand Down
103 changes: 103 additions & 0 deletions lib/android_apk/apksigner.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# frozen_string_literal: true

class AndroidApk
module ApkSigner
# [0]: entire, [1]: \d+, [2]: \d+
TARGET_SDK_PART_REGEX = /\(minSdkVersion=(\d+),\s*maxSdkVersion=(\d+)\)/i.freeze

# @param filepath [String] a file path of an apk file
# @return [Array<Array<String>>] a list of lines for each signer
module_function def lineage(filepath:)
args = [
"apksigner",
"lineage",
"--in",
filepath,
"--print-certs"
]

stdout, _, exit_status = Open3.capture3(*args)

if exit_status.success?
split_linage_signer_hunks(stdout: stdout)
else
[]
end
end

# @param filepath [String] a file path of an apk file
# @param min_sdk_version [String, Integer] min sdk version to verify
# @param max_sdk_version [String, Integer] max sdk version to verify
# @return [Hash<String => Array<String>>] a map of lines for each target sdk version range
module_function def verify(filepath:, min_sdk_version:, max_sdk_version:)
# Don't add -v because it will print pub keys too.
args = [
"apksigner",
"verify",
"--min-sdk-version",
min_sdk_version.to_s,
"--max-sdk-version",
max_sdk_version.to_s,
"--print-certs",
filepath
]
stdout, stderr, exit_status = Open3.capture3(*args)

if exit_status.success?
split_verify_signer_hunks(stdout: stdout, min_sdk_version: min_sdk_version, max_sdk_version: max_sdk_version)
else
return {} if stderr.downcase.include?("does not verify")

raise AndroidApk::ApkSignerExecutionError, "this file is a malformed apk"
end
end

module_function def split_linage_signer_hunks(stdout:)
signers = []
signer_index = 0

stdout.split("\n").each do |line|
unless (signer_number = line[/\ASigner\s#(\d+)\s/, 1]&.to_i).nil?
signer_index = signer_number - 1
end

signers[signer_index] ||= []

if line.start_with?("Has") || !line.include?("DN: CN")
signers[signer_index].push(line)
end
end

signers.compact
end

module_function def split_verify_signer_hunks(stdout:, min_sdk_version:, max_sdk_version:)
signers = {}

lines = stdout.split("\n").reject { |line| line.include?("WARNING") || line.include?("DN: CN") }

if lines[0].include?("minSdkVersion=")
lines.each do |line|
next if (m = line.match(TARGET_SDK_PART_REGEX)).nil?

sdk_versions = [m[1], m[2]]
signers[sdk_versions] ||= []
signers[sdk_versions].push(line)
end
else
# TODO: support multiple signers
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple key signing is not supported by Google Play or the general usecase of App Bundle, so this has been to TODOs.

lines.each do |line|
if !(signer_number = line[/\ASigner\s#(\d+)\s/, 1]&.to_i).nil? && (signer_number >= 2)
raise "Signer ##{signer_number} is found but it has not been supported yet"
end
end

sdk_versions = [min_sdk_version.to_s, max_sdk_version.to_s]

signers[sdk_versions] = lines
end

signers.compact
end
end
end
107 changes: 107 additions & 0 deletions lib/android_apk/app_signature.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

class AndroidApk
# @!attribute [r] fingerprints
# @return [Array<Hash<String => Any>>]
# @!attribute [r] lineages
# @return [Array<Hash<String => Any>>]
class AppSignature
V2_SCHEME_SDK_INT = 24
V3_SCHEME_SDK_INT = 28
V3_1_SCHEME_SDK_INT = 33

class << self
# @param [String] filepath to an apk file
# @param [Integer, String] min_sdk_version
# @return [AppSignature] a new signature
def parse(filepath:, min_sdk_version:)
lineages = ::AndroidApk::SignatureLineageReader.read(filepath: filepath)
fingerprints = ::AndroidApk::SignatureVerifier.verify(filepath: filepath, min_sdk_version: min_sdk_version)

AppSignature.new(
lineages: lineages,
fingerprints: fingerprints
)
end

# @param certificate_from [Hash<String => Any>, nil]
# @param app_signature_to [AppSignature] an signature object of the target apk
# @param sdk_version [Integer] the sdk version of the device
# @return [Hash<String => Any>, nil]
def get_target_certificate(certificate_from:, lineages_from:, app_signature_to:, sdk_version:)
# SHA1 or SHA256 is good
digest_method = ::AndroidApk::SignatureDigest::SHA1

# Deny if any fingerprint is unavailable for the sdk version
fingerprint_to = app_signature_to.get_fingerprint(sdk_version: sdk_version) or return

current_app_signing_digest = certificate_from[digest_method]
target_app_signing_digest = fingerprint_to[digest_method]

# Direct update
if target_app_signing_digest == current_app_signing_digest
return fingerprint_to.slice(::AndroidApk::SignatureDigest::MD5, ::AndroidApk::SignatureDigest::SHA1, ::AndroidApk::SignatureDigest::SHA256)
end

# rollback is a special case
if !(rollback_index = lineages_from.index { |lineage| lineage[digest_method] == target_app_signing_digest }).nil? && lineages_from[rollback_index]["rollback"]
return fingerprint_to.slice(::AndroidApk::SignatureDigest::MD5, ::AndroidApk::SignatureDigest::SHA1, ::AndroidApk::SignatureDigest::SHA256)
end

return nil if sdk_version < V3_SCHEME_SDK_INT

# Return true if the target apk contains the current signing signature.
jmatsu marked this conversation as resolved.
Show resolved Hide resolved
certificate = app_signature_to.lineages.find do |lineage|
lineage[digest_method] == target_app_signing_digest
end

certificate&.slice(::AndroidApk::SignatureDigest::MD5, ::AndroidApk::SignatureDigest::SHA1, ::AndroidApk::SignatureDigest::SHA256)
end
end

attr_reader :fingerprints, :lineages

def initialize(lineages:, fingerprints:)
@fingerprints = merge_fingerprints(fingerprints: fingerprints).freeze
@lineages = @fingerprints.empty? ? [].freeze : lineages.freeze # drop if unsigned
raise "lineages must be an empty or a list of 2 or more elements" if @lineages.size == 1
end

# @param [Integer] sdk_version
# @return [Hash<String => Any>, nil]
def get_fingerprint(sdk_version:)
# Ruby doesn't have TreeMap...
@fingerprints.find do |cert|
cert.fetch("min_sdk_version") <= sdk_version && sdk_version <= cert.fetch("max_sdk_version")
end
end

def unsigned?
@fingerprints.empty?
end

def rotated?
[email protected]?
end

private def merge_fingerprints(fingerprints:)
merged_fingerprints = fingerprints.each_with_object([]) do |fingerprint, acc|
if !(last_entry = acc.last).nil? && (last_entry.fetch(::AndroidApk::SignatureDigest::SHA256) == fingerprint.fetch(::AndroidApk::SignatureDigest::SHA256))
last_max_sdk_version = last_entry.fetch("max_sdk_version")
min_sdk_version = fingerprint.fetch("min_sdk_version")

if last_max_sdk_version + 1 == min_sdk_version || min_sdk_version <= last_max_sdk_version
last_entry["max_sdk_version"] = fingerprint["max_sdk_version"]
next
end
end

acc.push(fingerprint)
end

return [] if merged_fingerprints.size == 1 && merged_fingerprints[0].fetch(::AndroidApk::SignatureDigest::SHA256).nil?

merged_fingerprints
end
end
end
28 changes: 28 additions & 0 deletions lib/android_apk/signature_digest.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

class AndroidApk
module SignatureDigest
MD5 = "md5"
SHA1 = "sha1"
SHA256 = "sha256"

DIGEST_REGEX = /\A[0-9a-zA-Z]{32,}\z/.freeze

# @param digest [String]
# @return [String] digest method
def self.judge(digest:)
raise "only hex-digest is supported" unless digest =~ DIGEST_REGEX

case digest.length
when 32
::AndroidApk::SignatureDigest::MD5
when 40
::AndroidApk::SignatureDigest::SHA1
when 64
::AndroidApk::SignatureDigest::SHA256
else
raise "#{digest.length}-length digest is not supported"
end
end
end
end
Loading