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