diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt
index e8851d4317..7a6088c16b 100644
--- a/android/src/main/java/com/tailscale/ipn/App.kt
+++ b/android/src/main/java/com/tailscale/ipn/App.kt
@@ -150,10 +150,12 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
private fun initializeApp() {
// Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri()
+ val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
+ val hardwareAttestation = rm.applicationRestrictions.getBoolean(MDMSettings.KEY_HARDWARE_ATTESTATION, false)
if (storedUri != null && storedUri.toString().startsWith("content://")) {
- startLibtailscale(storedUri.toString())
+ startLibtailscale(storedUri.toString(), hardwareAttestation)
} else {
- startLibtailscale(this.filesDir.absolutePath)
+ startLibtailscale(this.filesDir.absolutePath, hardwareAttestation)
}
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -202,8 +204,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
* Called when a SAF directory URI is available (either already stored or chosen). We must restart
* Tailscale because directFileRoot must be set before LocalBackend starts being used.
*/
- fun startLibtailscale(directFileRoot: String) {
- app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
+ fun startLibtailscale(directFileRoot: String, hardwareAttestation: Boolean) {
+ app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, hardwareAttestation, this)
ShareFileHelper.init(this, app, directFileRoot, applicationScope)
Request.setApp(app)
Notifier.setApp(app)
diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt
index 34b341fc76..096e99be8f 100644
--- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt
+++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt
@@ -18,6 +18,9 @@ object MDMSettings {
// to the backend.
class NoSuchKeyException : Exception("no such key")
+ // MDM restriction keys
+ const val KEY_HARDWARE_ATTESTATION = "HardwareAttestation"
+
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
// Handled on the backed
@@ -115,6 +118,11 @@ object MDMSettings {
.map { it.call(MDMSettings) as MDMSetting<*> }
}
+ val hardwareAttestation = BooleanMDMSetting(
+ KEY_HARDWARE_ATTESTATION,
+ "Use hardware-backed keys to bind node identity to the device",
+ )
+
val allSettingsByKey by lazy { allSettings.associateBy { it.key } }
fun update(app: App, restrictionsManager: RestrictionsManager?) {
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index 97d7edc514..001774ae19 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -360,4 +360,8 @@
What is taildrop?
Open Directory Picker
+
+ Enable hardware attestation
+ Use hardware-backed keys to bind node identity to the device
+
diff --git a/android/src/main/res/xml/app_restrictions.xml b/android/src/main/res/xml/app_restrictions.xml
index b47cc58cd1..b313f7820f 100644
--- a/android/src/main/res/xml/app_restrictions.xml
+++ b/android/src/main/res/xml/app_restrictions.xml
@@ -148,4 +148,11 @@
android:key="OnboardingFlow"
android:restrictionType="choice"
android:title="@string/onboarding_flow" />
-
\ No newline at end of file
+
+
+
diff --git a/go.mod b/go.mod
index 2d2d417524..4d56063cb7 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,11 @@
module github.com/tailscale/tailscale-android
-go 1.25.1
+go 1.25.2
require (
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da
golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab
- tailscale.com v1.89.0-pre.0.20250929162250-7bcab4ab2841
+ tailscale.com v1.89.0-pre.0.20251010193330-005e264b5456
)
require (
diff --git a/go.sum b/go.sum
index 911c721b54..6f8c0c407d 100644
--- a/go.sum
+++ b/go.sum
@@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
-tailscale.com v1.89.0-pre.0.20250929162250-7bcab4ab2841 h1:BfBXlsl/ffzlJoTCQL78hVmGGdRm//h/75lKIWOX79o=
-tailscale.com v1.89.0-pre.0.20250929162250-7bcab4ab2841/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw=
+tailscale.com v1.89.0-pre.0.20251010193330-005e264b5456 h1:ELfWhOfTpC6wEHvD74NUwhvwQtGaR+fSmU7ldTTgBzU=
+tailscale.com v1.89.0-pre.0.20251010193330-005e264b5456/go.mod h1:gsjhGL2raodX0jQJ6uTD5dWJmc1DFtf5nQ1MRpzCReU=
diff --git a/go.toolchain.rev b/go.toolchain.rev
index 1fd4f3df25..d5de795585 100644
--- a/go.toolchain.rev
+++ b/go.toolchain.rev
@@ -1 +1 @@
-aa85d1541af0921f830f053f29d91971fa5838f6
+a80a86e575c5b7b23b78540e947335d22f74d274
diff --git a/libtailscale/backend.go b/libtailscale/backend.go
index e7cbb78f05..864136c33e 100644
--- a/libtailscale/backend.go
+++ b/libtailscale/backend.go
@@ -60,7 +60,7 @@ type App struct {
backendMu sync.Mutex
}
-func start(dataDir, directFileRoot string, appCtx AppContext) Application {
+func start(dataDir, directFileRoot string, hwAttestationPref bool, appCtx AppContext) Application {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in Start %s: %s", p, debug.Stack())
@@ -84,7 +84,7 @@ func start(dataDir, directFileRoot string, appCtx AppContext) Application {
os.Setenv("HOME", dataDir)
}
- return newApp(dataDir, directFileRoot, appCtx)
+ return newApp(dataDir, directFileRoot, hwAttestationPref, appCtx)
}
type backend struct {
@@ -111,7 +111,7 @@ type backend struct {
type settingsFunc func(*router.Config, *dns.OSConfig) error
-func (a *App) runBackend(ctx context.Context) error {
+func (a *App) runBackend(ctx context.Context, hardwareAttestation bool) error {
paths.AppSharedDir.Store(a.dataDir)
hostinfo.SetOSVersion(a.osVersion())
hostinfo.SetPackage(a.appCtx.GetInstallSource())
@@ -139,6 +139,9 @@ func (a *App) runBackend(ctx context.Context) error {
}
a.logIDPublicAtomic.Store(&b.logIDPublic)
a.backend = b.backend
+ if hardwareAttestation {
+ a.backend.SetHardwareAttested()
+ }
defer b.CloseTUNs()
hc := localapi.HandlerConfig{
diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go
index 14c5694bec..7155f06f82 100644
--- a/libtailscale/interfaces.go
+++ b/libtailscale/interfaces.go
@@ -11,8 +11,8 @@ import (
// Start starts the application, storing state in the given dataDir and using
// the given appCtx.
-func Start(dataDir, directFileRoot string, appCtx AppContext) Application {
- return start(dataDir, directFileRoot, appCtx)
+func Start(dataDir, directFileRoot string, hwAttestationPref bool, appCtx AppContext) Application {
+ return start(dataDir, directFileRoot, hwAttestationPref, appCtx)
}
// AppContext provides a context within which the Application is running. This
diff --git a/libtailscale/keystore.go b/libtailscale/keystore.go
index b803de9444..dfde42af1a 100644
--- a/libtailscale/keystore.go
+++ b/libtailscale/keystore.go
@@ -91,5 +91,15 @@ func (k *hardwareAttestationKey) Close() error {
}
func (k *hardwareAttestationKey) Clone() key.HardwareAttestationKey {
+ if k == nil {
+ return nil
+ }
return &hardwareAttestationKey{appCtx: k.appCtx, id: k.id, public: k.public}
}
+
+func (k *hardwareAttestationKey) IsZero() bool {
+ if k == nil {
+ return true
+ }
+ return k.id == ""
+}
diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go
index 76dc979572..63b403229f 100644
--- a/libtailscale/tailscale.go
+++ b/libtailscale/tailscale.go
@@ -32,7 +32,7 @@ const (
customLoginServerPrefKey = "customloginserver"
)
-func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
+func newApp(dataDir, directFileRoot string, hardwareAttestationPref bool, appCtx AppContext) Application {
a := &App{
directFileRoot: directFileRoot,
dataDir: dataDir,
@@ -44,7 +44,9 @@ 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() {
+
+ hwAttestEnabled := appCtx.HardwareAttestationKeySupported() && hardwareAttestationPref
+ if hwAttestEnabled {
key.RegisterHardwareAttestationKeyFns(
func() key.HardwareAttestationKey { return emptyHardwareAttestationKey(appCtx) },
func() (key.HardwareAttestationKey, error) { return createHardwareAttestationKey(appCtx) },
@@ -63,7 +65,7 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
}()
ctx := context.Background()
- if err := a.runBackend(ctx); err != nil {
+ if err := a.runBackend(ctx, hwAttestEnabled); err != nil {
fatalErr(err)
}
}()