Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
- name: Clean
run: make clean

- name: Format check (ktfmt)
run: make fmt-check

- name: Build APKs
run: make tailscale-debug.apk

Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/com/tailscale/ipn/IPNService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// This means that we were restarted after the service was killed
// (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) {
showForegroundNotification()
showForegroundNotification()
App.get()
Libtailscale.requestVPN(this)
START_STICKY
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import java.util.Date
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import java.util.Date

class Tailcfg {
@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,37 @@ import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow

/**
* Injects a fake Health.State for testing purposes.
* This bypasses the normal IPN bus notification flow.
* Injects a fake Health.State for testing purposes. This bypasses the normal IPN bus notification
* flow.
*/
fun Notifier.injectFakeHealthState(
includeHighSeverity: Boolean = true,
includeConnectivityImpact: Boolean = false,
customWarnings: List<Health.UnhealthyState> = emptyList()
) {
) {
val warnings = mutableMapOf<String, Health.UnhealthyState?>()

if (includeHighSeverity) {
warnings["test-high-severity"] = Health.UnhealthyState(
WarnableCode = "test-high-severity",
Severity = Health.Severity.high,
Title = "Test High Severity Warning",
Text = "This is a test warning with high severity",
ImpactsConnectivity = includeConnectivityImpact,
DependsOn = null
)
}

warnings["test-low-severity"] = Health.UnhealthyState(
WarnableCode = "test-low-severity",
Severity = Health.Severity.low,
Title = "Test Low Severity Warning",
Text = "This is a test warning with low severity",
ImpactsConnectivity = false,
DependsOn = null
)

customWarnings.forEach { warning ->
warnings[warning.WarnableCode] = warning
warnings["test-high-severity"] =
Health.UnhealthyState(
WarnableCode = "test-high-severity",
Severity = Health.Severity.high,
Title = "Test High Severity Warning",
Text = "This is a test warning with high severity",
ImpactsConnectivity = includeConnectivityImpact,
DependsOn = null)
}


warnings["test-low-severity"] =
Health.UnhealthyState(
WarnableCode = "test-low-severity",
Severity = Health.Severity.low,
Title = "Test Low Severity Warning",
Text = "This is a test warning with low severity",
ImpactsConnectivity = false,
DependsOn = null)

customWarnings.forEach { warning -> warnings[warning.WarnableCode] = warning }

(health as MutableStateFlow).set(Health.State(Warnings = warnings))
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,12 @@ fun SimpleActivityIndicator(size: Int = 32) {
fun ActivityIndicator(progress: Double, size: Int = 32) {
// LinearProgressIndicator defaults to a 4.dp height in Material3.
val height = 4.dp

LinearProgressIndicator(
progress = {progress.toFloat()},
progress = { progress.toFloat() },
modifier = Modifier.width(size.dp),
color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary,
gapSize = -height,
drawStopIndicator = {}
)
drawStopIndicator = {})
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,14 @@ fun SplitTunnelAppPickerView(
ListItem(
headlineContent = {
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
}
)
})
}
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
item("mdmIncludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
}
)
})
}
} else {
item("header") {
Expand All @@ -110,19 +108,15 @@ fun SplitTunnelAppPickerView(
if (allowSelected) R.string.selected_apps_will_access_tailscale
else
R.string
.selected_apps_will_access_the_internet_directly_without_using_tailscale
)
)
}
)
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
})
}
item("resolversHeader") {
Lists.SectionDivider(
stringResource(
if (allowSelected) R.string.count_included_apps else R.string.count_excluded_apps,
selectedPackageNames.count(),
)
)
))
}
if (installedApps.isEmpty()) {
item("spinner") {
Expand Down Expand Up @@ -200,8 +194,7 @@ fun FusMenu(viewModel: SplitTunnelAppPickerViewModel, onSwitchClick: (() -> Unit
text =
stringResource(
if (allowSelected) R.string.switch_to_select_to_exclude
else R.string.switch_to_select_to_include
),
else R.string.switch_to_select_to_include),
)
}
}
Expand All @@ -211,23 +204,20 @@ fun SwitchAlertDialog(allowSelected: Boolean, onConfirm: (() -> Unit), onDismiss
val switchString =
stringResource(
if (allowSelected) R.string.switch_to_select_to_exclude
else R.string.switch_to_select_to_include
)
else R.string.switch_to_select_to_include)
val switchDescription =
stringResource(
if (allowSelected)
R.string.selected_apps_will_access_the_internet_directly_without_using_tailscale
else R.string.selected_apps_will_access_tailscale
)
else R.string.selected_apps_will_access_tailscale)

AlertDialog(
title = { Text(text = "$switchString?") },
text = {
Text(
text =
stringResource(R.string.your_current_selection_will_be_cleared) +
"\n$switchDescription"
)
"\n$switchDescription")
},
onDismissRequest = onDismiss,
confirmButton = { TextButton(onClick = onConfirm) { Text(text = switchString) } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import java.time.Duration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
Expand All @@ -34,7 +35,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import java.time.Duration

class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
}
}
.intersect(installedApps.value.map { it.packageName }.toSet())
.toList()
)
.toList())
}

fun performSelectionSwitch() {
Expand Down
135 changes: 66 additions & 69 deletions android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,89 +12,86 @@ 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() {
// keyStoreKeys should be a singleton. Even if multiple HardwareKeyStores are created, we should
// not create distinct underlying key maps.
companion object {
val keyStoreKeys: HashMap<String, KeyPair> by lazy {
HashMap<String, KeyPair>()
}
}
val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply {
load(null)
}
// keyStoreKeys should be a singleton. Even if multiple HardwareKeyStores are created, we should
// not create distinct underlying key maps.
companion object {
val keyStoreKeys: HashMap<String, KeyPair> by lazy { 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
@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()
}

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)

kpg.initialize(parameterSpec)
val kp = kpg.generateKeyPair()
keyStoreKeys[id] = kp
return id
}

val kp = kpg.generateKeyPair()
keyStoreKeys[id] = kp
return id
}
fun releaseKey(id: String) {
keyStoreKeys.remove(id)
}

fun releaseKey(id: String) {
keyStoreKeys.remove(id)
fun sign(id: String, data: ByteArray): ByteArray {
val key = keyStoreKeys[id]
if (key == null) {
throw NoSuchKeyException()
}

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