Skip to content

Commit 11869b0

Browse files
authored
android,libtailscale: implement key.HardwareAttestationKey (#694)
Use a KeyStore-backed key to store a hardware-bound private key. Updates tailscale/tailscale#15830 Signed-off-by: Andrew Lytvynov <[email protected]>
1 parent 0de26e5 commit 11869b0

File tree

5 files changed

+249
-1
lines changed

5 files changed

+249
-1
lines changed

android/src/main/java/com/tailscale/ipn/App.kt

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import com.tailscale.ipn.ui.notifier.Notifier
3636
import com.tailscale.ipn.ui.viewModel.AppViewModel
3737
import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
3838
import com.tailscale.ipn.util.FeatureFlags
39+
import com.tailscale.ipn.util.HardwareKeyStore
40+
import com.tailscale.ipn.util.NoSuchKeyException
3941
import com.tailscale.ipn.util.ShareFileHelper
4042
import com.tailscale.ipn.util.TSLog
4143
import java.io.IOException
@@ -53,7 +55,7 @@ import kotlinx.coroutines.launch
5355
import kotlinx.serialization.encodeToString
5456
import kotlinx.serialization.json.Json
5557
import libtailscale.Libtailscale
56-
58+
import java.lang.UnsupportedOperationException
5759
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
5860
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
5961

@@ -359,6 +361,48 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
359361
fun notifyPolicyChanged() {
360362
app.notifyPolicyChanged()
361363
}
364+
365+
override fun hardwareAttestationKeySupported(): Boolean {
366+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
367+
packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
368+
} else {
369+
false
370+
}
371+
}
372+
373+
private lateinit var keyStore: HardwareKeyStore;
374+
375+
private fun getKeyStore(): HardwareKeyStore {
376+
if (hardwareAttestationKeySupported()) {
377+
return HardwareKeyStore()
378+
} else {
379+
throw UnsupportedOperationException()
380+
}
381+
}
382+
383+
override fun hardwareAttestationKeyCreate(): String {
384+
return getKeyStore().createKey()
385+
}
386+
387+
@Throws(NoSuchKeyException::class)
388+
override fun hardwareAttestationKeyRelease(id: String) {
389+
return getKeyStore().releaseKey(id)
390+
}
391+
392+
@Throws(NoSuchKeyException::class)
393+
override fun hardwareAttestationKeySign(id: String, data: ByteArray): ByteArray {
394+
return getKeyStore().sign(id, data)
395+
}
396+
397+
@Throws(NoSuchKeyException::class)
398+
override fun hardwareAttestationKeyPublic(id: String): ByteArray {
399+
return getKeyStore().public(id)
400+
}
401+
402+
@Throws(NoSuchKeyException::class)
403+
override fun hardwareAttestationKeyLoad(id: String) {
404+
return getKeyStore().load(id)
405+
}
362406
}
363407
/**
364408
* UninitializedApp contains all of the methods of App that can be used without having to initialize
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
package com.tailscale.ipn.util
4+
5+
import android.content.pm.PackageManager
6+
import android.os.Build
7+
import android.security.keystore.KeyGenParameterSpec
8+
import android.security.keystore.KeyProperties
9+
import java.security.KeyPair
10+
import java.security.KeyPairGenerator
11+
import java.security.KeyStore
12+
import java.security.Signature
13+
import kotlin.random.Random
14+
15+
class NoSuchKeyException : Exception("no key found matching the provided ID")
16+
class HardwareKeysNotSupported : Exception("hardware-backed keys are not supported on this device")
17+
18+
// HardwareKeyStore implements the callbacks necessary to implement key.HardwareAttestationKey on
19+
// the Go side. It uses KeyStore with a StrongBox processor.
20+
class HardwareKeyStore() {
21+
var keyStoreKeys = HashMap<String, KeyPair>();
22+
val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply {
23+
load(null)
24+
}
25+
26+
@OptIn(ExperimentalStdlibApi::class)
27+
fun newID(): String {
28+
var id: String
29+
do {
30+
id = Random.nextBytes(4).toHexString()
31+
} while (keyStoreKeys.containsKey(id))
32+
return id
33+
}
34+
35+
fun createKey(): String {
36+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
37+
throw HardwareKeysNotSupported()
38+
}
39+
val id = newID()
40+
val kpg: KeyPairGenerator = KeyPairGenerator.getInstance(
41+
KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore"
42+
)
43+
val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder(
44+
id, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
45+
).run {
46+
// Use DIGEST_NONE because hashing is done on the Go side.
47+
setDigests(KeyProperties.DIGEST_NONE)
48+
setIsStrongBoxBacked(true)
49+
build()
50+
}
51+
52+
kpg.initialize(parameterSpec)
53+
54+
val kp = kpg.generateKeyPair()
55+
keyStoreKeys[id] = kp
56+
return id
57+
}
58+
59+
fun releaseKey(id: String) {
60+
keyStoreKeys.remove(id)
61+
}
62+
63+
fun sign(id: String, data: ByteArray): ByteArray {
64+
val key = keyStoreKeys[id]
65+
if (key == null) {
66+
throw NoSuchKeyException()
67+
}
68+
// Use NONEwithECDSA because hashing is done on the Go side.
69+
return Signature.getInstance("NONEwithECDSA").run {
70+
initSign(key.private)
71+
update(data)
72+
sign()
73+
}
74+
}
75+
76+
fun public(id: String): ByteArray {
77+
val key = keyStoreKeys[id]
78+
if (key == null) {
79+
throw NoSuchKeyException()
80+
}
81+
return key.public.encoded
82+
}
83+
84+
fun load(id: String) {
85+
if (keyStoreKeys[id] != null) {
86+
// Already loaded.
87+
return
88+
}
89+
val entry: KeyStore.Entry = keyStore.getEntry(id, null)
90+
if (entry !is KeyStore.PrivateKeyEntry) {
91+
throw NoSuchKeyException()
92+
}
93+
keyStoreKeys[id] = KeyPair(entry.certificate.publicKey, entry.privateKey)
94+
}
95+
}

libtailscale/interfaces.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ type AppContext interface {
6565
// GetSyspolicyStringArrayValue returns the current string array value for the given system policy,
6666
// expressed as a JSON string.
6767
GetSyspolicyStringArrayJSONValue(key string) (string, error)
68+
69+
// Methods used to implement key.HardwareAttestationKey using the Android
70+
// KeyStore.
71+
HardwareAttestationKeySupported() bool
72+
HardwareAttestationKeyCreate() (id string, err error)
73+
HardwareAttestationKeyRelease(id string) error
74+
HardwareAttestationKeyPublic(id string) (pub []byte, err error)
75+
HardwareAttestationKeySign(id string, data []byte) (sig []byte, err error)
76+
HardwareAttestationKeyLoad(id string) error
6877
}
6978

7079
// IPNService corresponds to our IPNService in Java.

libtailscale/keystore.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package libtailscale
5+
6+
import (
7+
"crypto"
8+
"crypto/ecdsa"
9+
"crypto/x509"
10+
"encoding/json"
11+
"errors"
12+
"fmt"
13+
"io"
14+
15+
"tailscale.com/types/key"
16+
)
17+
18+
func emptyHardwareAttestationKey(appCtx AppContext) key.HardwareAttestationKey {
19+
return &hardwareAttestationKey{appCtx: appCtx}
20+
}
21+
22+
func createHardwareAttestationKey(appCtx AppContext) (key.HardwareAttestationKey, error) {
23+
id, err := appCtx.HardwareAttestationKeyCreate()
24+
if err != nil {
25+
return nil, err
26+
}
27+
k := &hardwareAttestationKey{appCtx: appCtx, id: id}
28+
if err := k.fetchPublic(); err != nil {
29+
return nil, err
30+
}
31+
return k, nil
32+
}
33+
34+
var hardwareAttestationKeyNotInitialized = errors.New("HardwareAttestationKey has not been initialized")
35+
36+
type hardwareAttestationKey struct {
37+
appCtx AppContext
38+
id string
39+
// public key is always initialized in createHardwareAttestationKey and
40+
// UnmarshalJSON. It's only nil in emptyHardwareAttestationKey.
41+
public *ecdsa.PublicKey
42+
}
43+
44+
func (k *hardwareAttestationKey) fetchPublic() error {
45+
if k.id == "" || k.appCtx == nil {
46+
return hardwareAttestationKeyNotInitialized
47+
}
48+
49+
pubRaw, err := k.appCtx.HardwareAttestationKeyPublic(k.id)
50+
if err != nil {
51+
return fmt.Errorf("loading public key from KeyStore: %w", err)
52+
}
53+
pubAny, err := x509.ParsePKIXPublicKey(pubRaw)
54+
if err != nil {
55+
return fmt.Errorf("parsing public key: %w", err)
56+
}
57+
pub, ok := pubAny.(*ecdsa.PublicKey)
58+
if !ok {
59+
return fmt.Errorf("parsed key is %T, expected *ecdsa.PublicKey", pubAny)
60+
}
61+
k.public = pub
62+
return nil
63+
}
64+
65+
func (k *hardwareAttestationKey) Public() crypto.PublicKey { return k.public }
66+
67+
func (k *hardwareAttestationKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
68+
if k.id == "" || k.appCtx == nil {
69+
return nil, hardwareAttestationKeyNotInitialized
70+
}
71+
return k.appCtx.HardwareAttestationKeySign(k.id, digest)
72+
}
73+
74+
func (k *hardwareAttestationKey) MarshalJSON() ([]byte, error) { return json.Marshal(k.id) }
75+
76+
func (k *hardwareAttestationKey) UnmarshalJSON(data []byte) error {
77+
if err := json.Unmarshal(data, &k.id); err != nil {
78+
return err
79+
}
80+
if err := k.appCtx.HardwareAttestationKeyLoad(k.id); err != nil {
81+
return fmt.Errorf("loading key with ID %q from KeyStore: %w", k.id, err)
82+
}
83+
return k.fetchPublic()
84+
}
85+
86+
func (k *hardwareAttestationKey) Close() error {
87+
if k.id == "" || k.appCtx == nil {
88+
return hardwareAttestationKeyNotInitialized
89+
}
90+
return k.appCtx.HardwareAttestationKeyRelease(k.id)
91+
}

libtailscale/tailscale.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"tailscale.com/logtail"
1717
"tailscale.com/logtail/filch"
1818
"tailscale.com/net/netmon"
19+
"tailscale.com/types/key"
1920
"tailscale.com/types/logger"
2021
"tailscale.com/types/logid"
2122
"tailscale.com/util/clientmetric"
@@ -43,6 +44,14 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
4344
a.policyStore = &syspolicyStore{a: a}
4445
netmon.RegisterInterfaceGetter(a.getInterfaces)
4546
rsop.RegisterStore("DeviceHandler", setting.DeviceScope, a.policyStore)
47+
if appCtx.HardwareAttestationKeySupported() {
48+
key.RegisterHardwareAttestationKeyFns(
49+
func() key.HardwareAttestationKey { return emptyHardwareAttestationKey(appCtx) },
50+
func() (key.HardwareAttestationKey, error) { return createHardwareAttestationKey(appCtx) },
51+
)
52+
} else {
53+
log.Printf("HardwareAttestationKey is not supported on this device")
54+
}
4655
go a.watchFileOpsChanges()
4756

4857
go func() {

0 commit comments

Comments
 (0)