blog.polynom.me/content/2023-07-24-Android-Yubikey-Signing.md
Alexander "PapaTutuWawa caef031d48
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Initial commit
2024-01-05 18:10:44 +01:00

6.6 KiB

+++ title = "Signing Android Apps Using a YubiKey (on NixOS)" date = "2023-07-24" template = "post.html" aliases = [ "/Android-Yubikey-Signing.html" ] +++ In my spare time, I currently develop two Android apps using Flutter: AniTrack, a simple anime and manga tracker based on my own needs, and Moxxy, a modern XMPP client. While I don't provide release builds for AniTrack, I do for Moxxy. Those are signed using the key-pair that Flutter generates. I thought to myself: "Wouldn't it be cool if I could keep the key-pair on a separate device which does the signing for me?". The consequence of this thought is that I bought a YubiKey 5c. However, as always, using it for my purposes did not go without issues.

The first issue is that the official Android documentation says to use the apksigner tool for creating the signature. The YubiKey documentation, however, uses jarsigner. While I, at first, did not think much of it, Android has different versions of the signature algorithm: v1 (what jarsigner does), v2, v3, v3.1 and v4. While it seems like it would be no problem to just use v1 signatures, Flutter, by default, generates v1 and v2 signatures, so I thought that I should keep it like that.

So, the solution is to just use apksigner instead of jarsigner, like another person on the Internet did. But that did not work for me. Running apksigner like that makes it complain that apksigner cannot access the required sun.security.pkcs11.SunPKCS11 Java class.

> /nix/store/ib27l0593bi4ybff06ndhpb8gyhx5zfv-android-sdk-env/share/android-sdk/build-tools/34.0.0/apksigner sign \ 
      --ks NONE \
      --ks-pass "pass:<YubiKey PIN>" \
      --provider-class sun.security.pkcs11.SunPKCS11 \
      --provider-arg ./provider.cfg \
      --ks-type PKCS11 \
      --min-sdk-version 24 \
      --max-sdk-version 34 \
      --in unsigned.apk \
      --out signed.apk

Exception in thread "main" java.lang.IllegalAccessException: class com.android.apksigner.ApkSignerTool$ProviderInstallSpec cannot access class sun.security.pkcs11.SunPKCS11 (in module jdk.crypto.cryptoki) because module jdk.crypto.cryptoki does not export sun.security.pkcs11 to unnamed module @75640fdb
        at java.base/jdk.internal.reflect.Reflection.newIllegalAccessException(Reflection.java:392)
        at java.base/java.lang.reflect.AccessibleObject.checkAccess(AccessibleObject.java:674)
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:489)
        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
        at com.android.apksigner.ApkSignerTool$ProviderInstallSpec.installProvider(ApkSignerTool.java:1233)
        at com.android.apksigner.ApkSignerTool$ProviderInstallSpec.access$200(ApkSignerTool.java:1201)
        at com.android.apksigner.ApkSignerTool.sign(ApkSignerTool.java:343)
        at com.android.apksigner.ApkSignerTool.main(ApkSignerTool.java:92)

It may only be an issue because I use NixOS, as I cannot find another instance of someone else having this issue. But I still want my APK signed using the key-pair on my YubiKey. After a lot of trial and error, I found out that I can force Java to export certain classes using the --add-exports flag. Since apksigner complained that the security classes are not exported to its unnamed class, I had to specify --add-exports sun.security.pkcs11.SunPKCS11=ALL-UNNAMED.

My Setup

TL;DR: I wrapped this entire setup (minus the Gradle config as that's a per-project thing) into a fancy script.

My provider configuration for the signature is exactly like the one provided in previously mentioned blog post, with the difference that I cannot use the specified path to the opensc-pkcs11.so as I am on NixOS, where such paths are not used. So in my setup, I either use the Nix REPL to build the derivation for opensc and then use its lib/opensc-pkcs11.so path (/nix/store/h2bn9iz4zqzmkmmjw9b43v30vhgillw4-opensc-0.22.0 in this case) for testing or, as used in AniTrack, let Nix figure out the path by building the config file from within my Nix Flake:

{
  # ...
  providerArg = pkgs.writeText "provider-arg.cfg" ''
    name = OpenSC-PKCS11
    description = SunPKCS11 via OpenSC
    library = ${pkgs.opensc}/lib/opensc-pkcs11.so
    slotListIndex = 0
  '';
  # ...
}

Next, to force Java to export the sun.security.pkcs11.SunPKCS11 class to apksigner's unnamed class, I added --add-exports sun.security.pkcs11.SunPKCS11 to the Java command line. There are two ways of doing this:

  1. Since apksigner is just a wrapper script around calling apksigner.jar, we could patch the wrapper script to include this parameter.
  2. Use the wrapper script's built-in mechanism to pass arguments to the java command.

While option 1 would work, it would require, in my case, to override the derivation that builds my Android SDK environment, which I am not that fond of. Using apksigner's way of specifying Java arguments (-J) is much easier. However, there is a little trick to it: When you pass -Jsomething to apksigner, the wrapper scripts transforms it to java -something. As such, we cannot pass -Jadd-exports sun.security.pkcs11.SunPKCS11 because it would get transformed to java -add-exports sun.security.[...], which is not what we want. To work around this, I quote the entire parameter to trick Bash into thinking that I'm passing a single argument: -J"-add-exports sun.security.pkcs11.SunPKCS11". This makes the wrapper append --add-exports sun.security.pkcs11.SunPKCS11 to the Java command line, ultimately allowing me to sign unsigned Android APKs with the key-pair on my YubiKey.

Since signing a signed APK makes little sense, we also need to tell Gradle to not sign the APK. In the case of Flutter apps, I modified the android/app/build.gradle file to use a null signing config:

android {
    // ...
    buildTypes {
        release {
            // This prevents Gradle from signing release builds.
            // I don't care what happens to debug builds as I'm not distributing them.
            signingConfig null
        }
    }
}