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 <awly@tailscale.com>
mjf/bumposs
Andrew Lytvynov 3 months ago committed by GitHub
parent 0de26e52c0
commit 11869b00c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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

@ -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<String, KeyPair>();
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)
}
}

@ -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.

@ -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)
}

@ -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() {

Loading…
Cancel
Save