I used Tergent to setup a hardware-managed SSH key with Termux and Termux:API. This cannot use existing SSH keys and will require generating a new key. Instructions:

  1. Install the Tergent package:

    pkg install tergent
  2. Create a new EC key with a 60-second timeout. The EC curve can be secp256r1, secp384r1 or secp521r1, but 521 is sometimes not usable. Replace $device with an appropriate alias for the device (first line retrieves the device maker and model):

    device="$(echo $(getprop ro.product.manufacturer) $(getprop ro.product.model) | sed 's/[^A-Z0-9_-]/-/ig')"
    $PREFIX/libexec/termux-api Keystore -e command generate -e alias $device -e algorithm EC --ei purposes 12 --esa digests NONE,SHA-1,SHA-256,SHA-384,SHA-512 -e curve secp256r1 --ei validity 60
  3. Export the public key to local storage and manually confirm it has the new key alone:

    ssh-keygen -D $PREFIX/lib/libtergent.so > ~/.ssh/id_ecdsa.pub
  4. Tell SSH to use Tergent keys in ~/.ssh/config:

    Host *
        PKCS11Provider /data/data/com.termux/files/usr/lib/libtergent.so
  5. Deploy this SSH key to servers (-f is necessary since the private key file is missing). For GitHub, add via account settings:

    ssh-copy-id -f -i ~/.ssh/id_ecdsa.pub <username@server>

SSH will now automatically try this key, prompting for device unlock if necessary. However, Termux:API will notify of error UserNotAuthenticatedException each time. To avoid this error, you will need to have unlocked your phone within the past 60 seconds, or must invoke termux-fingerprint for a fresh unlock.

Key management

  • List keys: termux-keystore list
  • Delete a key: termux-keystore delete <alias>

Scripting device unlock

termux-fingerprint always exits with 0, so we have to parse the output instead:

if [ $(termux-fingerprint | jq -r .auth_result) = "AUTH_RESULT_SUCCESS" ]; then
  echo Insert authenticated flow here
else
  echo Insert non-authenticated flow here
fi

Sample outputs for successful, cancelled and failed attempts (notice the result code doesn’t match user interaction):

{
  "errors": [],
  "failed_attempts": 0,
  "auth_result": "AUTH_RESULT_SUCCESS"
}
{
  "errors": [],
  "failed_attempts": 0,
  "auth_result": "AUTH_RESULT_FAILURE"
}
{
  "errors": [
    "ERROR_TIMEOUT"
  ],
  "failed_attempts": 4,
  "auth_result": "AUTH_RESULT_UNKNOWN"
}
{
  "errors": [
    "ERROR_NO_HARDWARE",
    "ERROR_NO_ENROLLED_FINGERPRINTS"
  ],
  "failed_attempts": 0,
  "auth_result": "AUTH_RESULT_UNKNOWN"
}

Tergent contributor Josh Dague suggests another approach:

… I’ve modified ~/.ssh/config to include:

Match exec termux-fingerprint
    PKCS11Provider /data/data/com.termux/files/usr/lib/libtergent.so

This causes ssh to invoke the fingerprint UI without relying on a shell alias. This is slightly nicer, although you still have to reauthenticate every time.

Devices without fingerprint scanners

Things break when the device has no fingerprint scanner. On an Onyx Boox e-reader, I get ERROR_NO_HARDWAREand the authentication fails. However, the hardware secure enclave does exist, and device unlock makes the SSH key available. It just cannot be triggered using termux-fingerprint. This is inconvenient on a Boox since device lock disconnects Wi-Fi and unlock takes several seconds for the network to be available again. I’ve filed a ticket asking for unlock with device credentials.

Workaround

I have the following in my Obsidian vault sync script just before git pull --rebase && git push. This avoids the error notification, has a fallback for devices without biometric hardware, and the 60 second timeout should be long enough for two uses between the pull and push:

# In Termux, unlock device credentials
if command -v termux-fingerprint >/dev/null; then
  if termux-fingerprint | grep ERROR_NO_HARDWARE >/dev/null; then
    # If there is no biometric hardware, use device unlock
    am start -a android.app.action.CONFIRM_DEVICE_CREDENTIAL > /dev/null
    read -rsn 1 -p "Press any key to continue"
    echo
  fi
fi

Security

The SSH key becomes available for 60 seconds after each device unlock. I think the key is only available to Termux but I have not confirmed this.

The key is stored in the hardware secure enclave. The private key cannot be extracted, even with root access. If you lose or retire your device, you must remove the public key from your servers as the private key cannot be transferred to another device.

Algorithms

SSH keys typically use RSA, Elliptic Curve (EC) Digital Signature Algorithm (DSA), or Edwards-curve DSA (Ed25519). RSA is insecure below 2048 bits and EC has a conflicted history, while Ed25519 has a clean record. Modern ssh-keygen will default to Ed25519. However, Android Keystore only supports RSA and EC (see the full KEY_ALGORITHM_* list), so we use EC.

For other platforms

  • macOS: Secretive (also uses secp256r1). The UI is obvious enough to not need usage notes.
  • Linux: todo
  • Portable: something with yubikey or keepassxc?
  • Code signing: ideally should not use device-bound authentication keys. Since the public key must be stable for long term verifiability, private key management needs extra care. todo