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:
- Since
apksigner
is just a wrapper script around callingapksigner.jar
, we could patch the wrapper script to include this parameter. - 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
}
}
}