From 11869b00c56850c9b9c6a6d25794e5ec33484490 Mon Sep 17 00:00:00 2001 From: Andrew Lytvynov Date: Mon, 15 Sep 2025 10:09:34 -0700 Subject: [PATCH] android,libtailscale: implement key.HardwareAttestationKey (#694) Use a KeyStore-backed key to store a hardware-bound private key. Updates https://github.com/tailscale/tailscale/issues/15830 Signed-off-by: Andrew Lytvynov --- .../src/main/java/com/tailscale/ipn/App.kt | 46 ++++++++- .../tailscale/ipn/util/HardwareKeyStore.kt | 95 +++++++++++++++++++ libtailscale/interfaces.go | 9 ++ libtailscale/keystore.go | 91 ++++++++++++++++++ libtailscale/tailscale.go | 9 ++ 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt create mode 100644 libtailscale/keystore.go diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index f89821e..e8851d4 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -36,6 +36,8 @@ import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.AppViewModelFactory import com.tailscale.ipn.util.FeatureFlags +import com.tailscale.ipn.util.HardwareKeyStore +import com.tailscale.ipn.util.NoSuchKeyException import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog import java.io.IOException @@ -53,7 +55,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale - +import java.lang.UnsupportedOperationException class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -359,6 +361,48 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { fun notifyPolicyChanged() { app.notifyPolicyChanged() } + + override fun hardwareAttestationKeySupported(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + } else { + false + } + } + + private lateinit var keyStore: HardwareKeyStore; + + private fun getKeyStore(): HardwareKeyStore { + if (hardwareAttestationKeySupported()) { + return HardwareKeyStore() + } else { + throw UnsupportedOperationException() + } + } + + override fun hardwareAttestationKeyCreate(): String { + return getKeyStore().createKey() + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeyRelease(id: String) { + return getKeyStore().releaseKey(id) + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeySign(id: String, data: ByteArray): ByteArray { + return getKeyStore().sign(id, data) + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeyPublic(id: String): ByteArray { + return getKeyStore().public(id) + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeyLoad(id: String) { + return getKeyStore().load(id) + } } /** * UninitializedApp contains all of the methods of App that can be used without having to initialize diff --git a/android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt b/android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt new file mode 100644 index 0000000..24b9d98 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt @@ -0,0 +1,95 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn.util + +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Signature +import kotlin.random.Random + +class NoSuchKeyException : Exception("no key found matching the provided ID") +class HardwareKeysNotSupported : Exception("hardware-backed keys are not supported on this device") + +// HardwareKeyStore implements the callbacks necessary to implement key.HardwareAttestationKey on +// the Go side. It uses KeyStore with a StrongBox processor. +class HardwareKeyStore() { + var keyStoreKeys = HashMap(); + val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + + @OptIn(ExperimentalStdlibApi::class) + fun newID(): String { + var id: String + do { + id = Random.nextBytes(4).toHexString() + } while (keyStoreKeys.containsKey(id)) + return id + } + + fun createKey(): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + throw HardwareKeysNotSupported() + } + val id = newID() + val kpg: KeyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore" + ) + val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder( + id, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY + ).run { + // Use DIGEST_NONE because hashing is done on the Go side. + setDigests(KeyProperties.DIGEST_NONE) + setIsStrongBoxBacked(true) + build() + } + + kpg.initialize(parameterSpec) + + val kp = kpg.generateKeyPair() + keyStoreKeys[id] = kp + return id + } + + fun releaseKey(id: String) { + keyStoreKeys.remove(id) + } + + fun sign(id: String, data: ByteArray): ByteArray { + val key = keyStoreKeys[id] + if (key == null) { + throw NoSuchKeyException() + } + // Use NONEwithECDSA because hashing is done on the Go side. + return Signature.getInstance("NONEwithECDSA").run { + initSign(key.private) + update(data) + sign() + } + } + + fun public(id: String): ByteArray { + val key = keyStoreKeys[id] + if (key == null) { + throw NoSuchKeyException() + } + return key.public.encoded + } + + fun load(id: String) { + if (keyStoreKeys[id] != null) { + // Already loaded. + return + } + val entry: KeyStore.Entry = keyStore.getEntry(id, null) + if (entry !is KeyStore.PrivateKeyEntry) { + throw NoSuchKeyException() + } + keyStoreKeys[id] = KeyPair(entry.certificate.publicKey, entry.privateKey) + } +} \ No newline at end of file diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index ca13070..14c5694 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -65,6 +65,15 @@ type AppContext interface { // GetSyspolicyStringArrayValue returns the current string array value for the given system policy, // expressed as a JSON string. GetSyspolicyStringArrayJSONValue(key string) (string, error) + + // Methods used to implement key.HardwareAttestationKey using the Android + // KeyStore. + HardwareAttestationKeySupported() bool + HardwareAttestationKeyCreate() (id string, err error) + HardwareAttestationKeyRelease(id string) error + HardwareAttestationKeyPublic(id string) (pub []byte, err error) + HardwareAttestationKeySign(id string, data []byte) (sig []byte, err error) + HardwareAttestationKeyLoad(id string) error } // IPNService corresponds to our IPNService in Java. diff --git a/libtailscale/keystore.go b/libtailscale/keystore.go new file mode 100644 index 0000000..20150dc --- /dev/null +++ b/libtailscale/keystore.go @@ -0,0 +1,91 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "crypto" + "crypto/ecdsa" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + + "tailscale.com/types/key" +) + +func emptyHardwareAttestationKey(appCtx AppContext) key.HardwareAttestationKey { + return &hardwareAttestationKey{appCtx: appCtx} +} + +func createHardwareAttestationKey(appCtx AppContext) (key.HardwareAttestationKey, error) { + id, err := appCtx.HardwareAttestationKeyCreate() + if err != nil { + return nil, err + } + k := &hardwareAttestationKey{appCtx: appCtx, id: id} + if err := k.fetchPublic(); err != nil { + return nil, err + } + return k, nil +} + +var hardwareAttestationKeyNotInitialized = errors.New("HardwareAttestationKey has not been initialized") + +type hardwareAttestationKey struct { + appCtx AppContext + id string + // public key is always initialized in createHardwareAttestationKey and + // UnmarshalJSON. It's only nil in emptyHardwareAttestationKey. + public *ecdsa.PublicKey +} + +func (k *hardwareAttestationKey) fetchPublic() error { + if k.id == "" || k.appCtx == nil { + return hardwareAttestationKeyNotInitialized + } + + pubRaw, err := k.appCtx.HardwareAttestationKeyPublic(k.id) + if err != nil { + return fmt.Errorf("loading public key from KeyStore: %w", err) + } + pubAny, err := x509.ParsePKIXPublicKey(pubRaw) + if err != nil { + return fmt.Errorf("parsing public key: %w", err) + } + pub, ok := pubAny.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("parsed key is %T, expected *ecdsa.PublicKey", pubAny) + } + k.public = pub + return nil +} + +func (k *hardwareAttestationKey) Public() crypto.PublicKey { return k.public } + +func (k *hardwareAttestationKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) { + if k.id == "" || k.appCtx == nil { + return nil, hardwareAttestationKeyNotInitialized + } + return k.appCtx.HardwareAttestationKeySign(k.id, digest) +} + +func (k *hardwareAttestationKey) MarshalJSON() ([]byte, error) { return json.Marshal(k.id) } + +func (k *hardwareAttestationKey) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &k.id); err != nil { + return err + } + if err := k.appCtx.HardwareAttestationKeyLoad(k.id); err != nil { + return fmt.Errorf("loading key with ID %q from KeyStore: %w", k.id, err) + } + return k.fetchPublic() +} + +func (k *hardwareAttestationKey) Close() error { + if k.id == "" || k.appCtx == nil { + return hardwareAttestationKeyNotInitialized + } + return k.appCtx.HardwareAttestationKeyRelease(k.id) +} diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index c03e6f5..76dc979 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -16,6 +16,7 @@ import ( "tailscale.com/logtail" "tailscale.com/logtail/filch" "tailscale.com/net/netmon" + "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/util/clientmetric" @@ -43,6 +44,14 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a.policyStore = &syspolicyStore{a: a} netmon.RegisterInterfaceGetter(a.getInterfaces) rsop.RegisterStore("DeviceHandler", setting.DeviceScope, a.policyStore) + if appCtx.HardwareAttestationKeySupported() { + key.RegisterHardwareAttestationKeyFns( + func() key.HardwareAttestationKey { return emptyHardwareAttestationKey(appCtx) }, + func() (key.HardwareAttestationKey, error) { return createHardwareAttestationKey(appCtx) }, + ) + } else { + log.Printf("HardwareAttestationKey is not supported on this device") + } go a.watchFileOpsChanges() go func() {