1. 程式人生 > >美團新一代渠道包打包神器walle

美團新一代渠道包打包神器walle

背景:

Android 7.0 中新增了 APK Signature Scheme v2 簽名方式

如果Android Studio升級到 v2.2+,構建APK時預設使用的簽名方式就是APK Signature Scheme v2

目前比較流行的2套 多渠道打包指令碼

  • 在APK內注入${channel}.txt 檔案
  • 在APK的zip info中寫入 channel 資訊

實質上都會在簽名後修改APK檔案,目前都會造成 簽名認證失敗

官方介紹 https://tech.meituan.com/android-apk-v2-signature-scheme.html

因為在Android 7.0(Nougat)推出了新的應用簽名方案APK Signature Scheme v2,用之前快速生成渠道包的方式(美團Android自動化之旅—生成渠道包)打出來的包,沒法簽名。因此有了現在新的渠道包打包工具walle。

新的應用簽名方案APK Signature Scheme v2

Android 7.0(Nougat)引入一項新的應用簽名方案APK Signature Scheme v2,它是一個對全檔案進行簽名的方案,能提供更快的應用安裝時間、對未授權APK檔案的更改提供更多保護,在預設情況下,Android Gradle 2.2.0外掛會使用APK Signature Scheme v2和傳統簽名方案來簽署你的應用。

下面以 新的應用簽名方案 來指APK Signature Scheme v2。

目前該方案不是強制性的,在 build.gradle 新增 v2SigningEnabled false ,就能使用傳統簽名方案來簽署我們的應用(見下面的程式碼片段)。

  android {
    ...
    defaultConfig { ... }
    signingConfigs {
      release {
        storeFile file("myreleasekey.keystore")
        storePassword "password"
        keyAlias "MyReleaseKey"
        keyPassword "password"
        v2SigningEnabled false
      }
    }
  }

但新的應用簽名方案有著良好的向後相容性,能完全相容低於Android 7.0(Nougat)的版本。對比舊簽名方案,它有更快的驗證速度和更安全的保護,因此新的應用簽名方案可能會被採納成一個強制配置,筆者認為現在有必要對現有的渠道包生成方式進行檢查、升級或改造來支援新的應用簽名方案。

新的簽名方案對已有的渠道生成方案有什麼影響呢?下圖是新的應用簽名方案和舊的簽名方案的一個對比:

新的簽名方案會在ZIP檔案格式的 Central Directory 區塊所在檔案位置的前面新增一個APK Signing Block區塊,下面按照ZIP檔案的格式來分析新應用簽名方案簽名後的APK包。

整個APK(ZIP檔案格式)會被分為以下四個區塊:

  1. Contents of ZIP entries(from offset 0 until the start of APK Signing Block)
  2. APK Signing Block
  3. ZIP Central Directory
  4. ZIP End of Central Directory

新應用簽名方案的簽名信息會被儲存在區塊2(APK Signing Block)中, 而區塊1(Contents of ZIP entries)、區塊3(ZIP Central Directory)、區塊4(ZIP End of Central Directory)是受保護的,在簽名後任何對區塊1、3、4的修改都逃不過新的應用簽名方案的檢查。

之前的渠道包生成方案是通過在META-INF目錄下新增空檔案,用空檔案的名稱來作為渠道的唯一標識,之前在META-INF下新增檔案是不需要重新簽名應用的,這樣會節省不少打包的時間,從而提高打渠道包的速度。但在新的應用簽名方案下META-INF已經被列入了保護區了,向META-INF新增空檔案的方案會對區塊1、3、4都會有影響,新應用簽名方案簽署的應用經過我們舊的生成渠道包方案處理後,在安裝時會報以下錯誤:

Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES: 
Failed to collect certificates from base.apk: META-INF/CERT.SF indicates base.apk is signed using APK Signature Scheme v2, 
but no such signature was found. Signature stripped?]

目前另外一種比較流行的渠道包快速生成方案(往APK中新增ZIP Comment)也因為上述原因,無法在新的應用簽名方案下進行正常工作。

如果新的應用簽名方案後續改成強制要求,那我們現有的生成渠道包的方式就會無法工作,那我們難道要退回到解放前,通過傳統的方式(例如:使用APKTool逆向工具、採用Flavor + BuildType等比較耗時的方案來進行渠道包打包)來生成支援新應用簽名方案的渠道包嗎?

如果只有少量渠道包的場景下,這種耗時時長還能夠勉強接受。但是目前我們有將近900個渠道,如果採用傳統方式打完所有的渠道包需要近3個小時,這是不能接受的。

那我們有沒有其他更好的渠道包生成方式,既能支援新的應用簽名方案,又能體驗毫秒級的打包耗時呢?我們來分析一下新方案中的區塊2——Block。

可擴充套件的APK Signature Scheme v2 Block

通過上面的描述,可以看出因為APK包的區塊1、3、4都是受保護的,任何修改在簽名後對它們的修改,都會在安裝過程中被簽名校驗檢測失敗,而區塊2(APK Signing Block)是不受簽名校驗規則保護的,那是否可以在這個不受簽名保護的區塊2(APK Signing Block)上做文章呢?我們先來看看對區塊2格式的描述:

偏移 位元組數 描述
@+0 8 這個Block的長度(本欄位的長度不計算在內)
@+8 n 一組ID-value
@-24 8 這個Block的長度(和第一個欄位一樣值)
@-16 16 魔數 “APK Sig Block 42”

區塊2中APK Signing Block是由這幾部分組成:2個用來標示這個區塊長度的8位元組 + 這個區塊的魔數(APK Sig Block 42)+ 這個區塊所承載的資料(ID-value)。

我們重點來看一下這個ID-value,它由一個8位元組的長度標示+4位元組的ID+它的負載組成。V2的簽名信息是以ID(0x7109871a)的ID-value來儲存在這個區塊中,不知大家有沒有注意這是一組ID-value,也就是說它是可以有若干個這樣的ID-value來組成,那我們是不是可以在這裡做一些文章呢?

為了驗證我們的想法,先來看看新的應用簽名方案是怎麼驗證簽名信息的,見下圖:

通過上圖可以看出新的應用簽名方案的驗證過程:

  1. 尋找APK Signing Block,如果能夠找到,則進行驗證,驗證成功則繼續進行安裝,如果失敗了則終止安裝
  2. 如果未找到APK Signing Block,則執行原來的簽名驗證機制,也是驗證成功則繼續進行安裝,如果失敗了則終止安裝

那Android應用在安裝時新的應用簽名方案是怎麼進行校驗的呢?筆者通過翻閱Android相關部分的原始碼,發現下面程式碼段是用來處理上面所說的ID-value的:

    public static ByteBuffer findApkSignatureSchemeV2Block(
            ByteBuffer apkSigningBlock,
            Result result) throws SignatureNotFoundException {
        checkByteOrderLittleEndian(apkSigningBlock);
        // FORMAT:
        // OFFSET       DATA TYPE  DESCRIPTION
        // * @+0  bytes uint64:    size in bytes (excluding this field)
        // * @+8  bytes pairs
        // * @-24 bytes uint64:    size in bytes (same as the one above)
        // * @-16 bytes uint128:   magic
        ByteBuffer pairs = sliceFromTo(apkSigningBlock, 8, apkSigningBlock.capacity() - 24);

        int entryCount = 0;
        while (pairs.hasRemaining()) {
            entryCount++;
            if (pairs.remaining() < 8) {
                throw new SignatureNotFoundException(
                        "Insufficient data to read size of APK Signing Block entry #" + entryCount);
            }
            long lenLong = pairs.getLong();
            if ((lenLong < 4) || (lenLong > Integer.MAX_VALUE)) {
                throw new SignatureNotFoundException(
                        "APK Signing Block entry #" + entryCount
                                + " size out of range: " + lenLong);
            }
            int len = (int) lenLong;
            int nextEntryPos = pairs.position() + len;
            if (len > pairs.remaining()) {
                throw new SignatureNotFoundException(
                        "APK Signing Block entry #" + entryCount + " size out of range: " + len
                                + ", available: " + pairs.remaining());
            }
            int id = pairs.getInt();
            if (id == APK_SIGNATURE_SCHEME_V2_BLOCK_ID) {
                return getByteBuffer(pairs, len - 4);
            }
            result.addWarning(Issue.APK_SIG_BLOCK_UNKNOWN_ENTRY_ID, id);
            pairs.position(nextEntryPos);
        }

        throw new SignatureNotFoundException(
                "No APK Signature Scheme v2 block in APK Signing Block");
    }

上述程式碼中關鍵的一個位置是 if (id == APK_SIGNATURE_SCHEME_V2_BLOCK_ID) {return getByteBuffer(pairs, len - 4);},通過原始碼可以看出Android是通過查詢ID為 APK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871a 的ID-value,來獲取APK Signature Scheme v2 Block,對這個區塊中其他的ID-value選擇了忽略。

APK Signature Scheme v2中沒有看到對無法識別的ID,有相關處理的介紹。

當看到這裡時,我們可不可以設想一下,提供一個自定義的ID-value並寫入該區域,從而為快速生成渠道包服務呢?

怎麼向ID-value中新增資訊呢?通過閱讀ZIP的檔案格式和APK Signing Block格式的描述,筆者通過編寫下面的程式碼片段進行驗證,發現通過在已經被新的應用簽名方案簽名後的APK中新增自定義的ID-value,是不需要再次經過簽名就能安裝的,下面是部分程式碼片段。

  public void writeApkSigningBlock(DataOutput dataOutput) {
        long length = 24;
        for (int index = 0; index < payloads.size(); ++index) {
            ApkSigningPayload payload = payloads.get(index);
            byte[] bytes = payload.getByteBuffer();
            length += 12 + bytes.length;
        }

        ByteBuffer byteBuffer = ByteBuffer.allocate(Long.BYTES);
        byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
        byteBuffer.putLong(length);
        dataOutput.write(byteBuffer.array());

        for (int index = 0; index < payloads.size(); ++index) {
            ApkSigningPayload payload = payloads.get(index);
            byte[] bytes = payload.getByteBuffer();

            byteBuffer = ByteBuffer.allocate(Integer.BYTES);
            byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
            byteBuffer.putInt(payload.getId());
            dataOutput.write(byteBuffer.array());

            dataOutput.write(bytes);
        }
        ...
    }

新一代渠道包生成工具

到這裡為止一個新的渠道包生成方案逐步清晰了起來,下面是新一代渠道包生成工具的描述:

  1. 對新的應用簽名方案生成的APK包中的ID-value進行擴充套件,提供自定義ID-value(渠道資訊),並儲存在APK中
  2. 而APK在安裝過程中進行的簽名校驗,是忽略我們新增的這個ID-value的,這樣就能正常安裝了
  3. 在App執行階段,可以通過ZIP的EOCD(End of central directory)Central directory等結構中的資訊(會涉及ZIP格式的相關知識,這裡不做展開描述)找到我們自己新增的ID-value,從而實現獲取渠道資訊的功能

新一代渠道包生成工具完全是基於ZIP檔案格式和APK Signing Block儲存格式而構建,基於檔案的二進位制流進行處理,有著良好的處理速度和相容性,能夠滿足不同的語言編寫的要求,目前筆者採用的是Java+Groovy開發, 該工具主要有四部分組成:

  1. 用於寫入ID-value資訊的Java類庫
  2. Gradle構建外掛用來和Android的打包流程進行結合
  3. 用於讀取ID-value資訊的Java類庫
  4. 用於供com.android.application使用的讀取渠道資訊的AAR

這樣,每打一個渠道包只需複製一個APK,然後在APK中新增一個ID-value即可,這種打包方式速度非常快,對一個30M大小的APK包只需要100多毫秒(包含檔案複製時間)就能生成一個渠道包,而在執行時獲取渠道資訊只需要大約幾毫秒的時間。

這個專案我們取名為Walle(瓦力),已經開源,專案的Github地址是: https://github.com/Meituan-Dianping/walle (求Issue、PR、Star)。希望業內有類似需求的團隊能夠在APK Signature Scheme V2簽名下愉快地生成渠道包,同時也期待大家一起對該專案進行完善和優化。

總結

以上就是我們對新的應用簽名方案進行的分析,並根據它所帶來的檔案儲存格式上的變化,找到了可以利用的ID-value,然後基於這個ID-value來構建我們新一代渠道包生成工具。

新一代渠道包生成工具能夠滿足新應用簽名方案對安全性的要求,同時也能滿足對渠道包打包時間的要求,至此大家生成渠道包的方式需要升級了!

文章中引用的圖片來源於:https://source.android.com/security/apksigning/v2.html

 

 

上面是關於工具實現的介紹,下面介紹工具的使用:https://github.com/Meituan-Dianping/walle

 

 

Gradle外掛使用方式

配置build.gradle

在位於專案的根目錄 build.gradle 檔案中新增Walle Gradle外掛的依賴, 如下:

buildscript {
    dependencies {
        classpath 'com.meituan.android.walle:plugin:1.1.6'
    }
}
並在當前App的 build.gradle 檔案中apply這個外掛,並新增上用於讀取渠道號的AAR

apply plugin: 'walle'

dependencies {
    compile 'com.meituan.android.walle:library:1.1.6'
}
配置外掛

walle {
    // 指定渠道包的輸出路徑
    apkOutputFolder = new File("${project.buildDir}/outputs/channels");
    // 定製渠道包的APK的檔名稱
    apkFileNameFormat = '${appName}-${packageName}-${channel}-${buildType}-v${versionName}-${versionCode}-${buildTime}.apk';
    // 渠道配置檔案
    channelFile = new File("${project.getProjectDir()}/channel")
}
配置項具體解釋:

apkOutputFolder:指定渠道包的輸出路徑, 預設值為new File("${project.buildDir}/outputs/apk")

apkFileNameFormat:定製渠道包的APK的檔名稱, 預設值為'${appName}-${buildType}-${channel}.apk'
可使用以下變數:

     projectName - 專案名字
     appName - App模組名字
     packageName - applicationId (App包名packageName)
     buildType - buildType (release/debug等)
     channel - channel名稱 (對應渠道打包中的渠道名字)
     versionName - versionName (顯示用的版本號)
     versionCode - versionCode (內部版本號)
     buildTime - buildTime (編譯構建日期時間)
     fileSHA1 - fileSHA1 (最終APK檔案的SHA1雜湊值)
     flavorName - 編譯構建 productFlavors 名
channelFile:包含渠道配置資訊的檔案路徑。 具體內容格式詳見:渠道配置檔案示例,支援使用#號添加註釋。

如何獲取渠道資訊

在需要渠道等資訊時可以通過下面程式碼進行獲取

String channel = WalleChannelReader.getChannel(this.getApplicationContext());
如何生成渠道包

生成渠道包的方式是和assemble${variantName}Channels指令結合,渠道包的生成目錄預設存放在 build/outputs/apk/,也可以通過walle閉包中的apkOutputFolder引數來指定輸出目錄

用法示例:

生成渠道包 ./gradlew clean assembleReleaseChannels
支援 productFlavors ./gradlew clean assembleMeituanReleaseChannels

 

 

臨時生成某渠道包

我們推薦使用channelFile/configFile配置來生成渠道包,但有時也可能有臨時生成渠道包需求,這時可以使用:

生成單個渠道包: ./gradlew clean assembleReleaseChannels -PchannelList=meituan

生成多個渠道包: ./gradlew clean assembleReleaseChannels -PchannelList=meituan,dianping

 


基於360加固後簽名失效的解決方案:https://github.com/Jay-Goo/ProtectedApkResignerForWalle

 

其中的配置