Merge branch 'main' into task/improve-app-split-tunneling-feature
commit
9583c7af0a
@ -0,0 +1,26 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
// This class adapts a Java OutputStream to the libtailscale.OutputStream interface.
|
||||||
|
class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale.OutputStream {
|
||||||
|
// writes data to the outputStream in its entirety. Returns -1 on error.
|
||||||
|
override fun write(data: ByteArray): Long {
|
||||||
|
return try {
|
||||||
|
outputStream.write(data)
|
||||||
|
outputStream.flush()
|
||||||
|
data.size.toLong()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.d("OutputStreamAdapter", "write exception: $e")
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
outputStream.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.model.Permissions
|
||||||
|
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationsView(backToPermissionsView: BackNavigation, openApplicationSettings: () -> Unit) {
|
||||||
|
val permissions = Permissions.withGrantedStatus
|
||||||
|
|
||||||
|
// Find the notification permission
|
||||||
|
val notificationPermission =
|
||||||
|
permissions.find { (permission, _) ->
|
||||||
|
permission.title == R.string.permission_post_notifications
|
||||||
|
}
|
||||||
|
val granted = notificationPermission?.second ?: false
|
||||||
|
val permission = notificationPermission?.first
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(titleRes = R.string.permission_post_notifications, onBack = backToPermissionsView)
|
||||||
|
}) { innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
item {
|
||||||
|
if (permission != null) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(permission.title),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(permission.description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.notification_settings_explanation),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item("spacer") {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp)) // soft break instead of divider
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_post_notifications),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (granted) stringResource(R.string.on)
|
||||||
|
else stringResource(R.string.off),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Button(
|
||||||
|
colors = MaterialTheme.colorScheme.exitNodeToggleButton,
|
||||||
|
onClick = openApplicationSettings,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
|
Text(stringResource(R.string.open_notification_settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.friendlyDirName
|
||||||
|
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaildropDirView(
|
||||||
|
backToPermissionsView: BackNavigation,
|
||||||
|
openDirectoryLauncher: ActivityResultLauncher<Uri?>,
|
||||||
|
permissionsViewModel: PermissionsViewModel
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(titleRes = R.string.taildrop_dir_access, onBack = backToPermissionsView)
|
||||||
|
}) { innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.taildrop_dir_access),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_taildrop_dir),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
item("divider0") { Lists.SectionDivider() }
|
||||||
|
|
||||||
|
item {
|
||||||
|
val currentDir by permissionsViewModel.currentDir.collectAsState()
|
||||||
|
TSLog.d("TaildropDirView", "currentDir in UI: $currentDir")
|
||||||
|
val displayPath = currentDir?.let { friendlyDirName(it) } ?: "No access"
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.dir_access),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(text = displayPath, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Button(
|
||||||
|
colors = MaterialTheme.colorScheme.exitNodeToggleButton,
|
||||||
|
onClick = { openDirectoryLauncher.launch(null) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
|
Text(stringResource(R.string.pick_dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.util.ShareFileHelper
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class AppViewModelFactory(val application: Application, private val taildropPrompt: Flow<Unit>) :
|
||||||
|
ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
if (modelClass.isAssignableFrom(AppViewModel::class.java)) {
|
||||||
|
return AppViewModel(application, taildropPrompt) as T
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application context-aware ViewModel used to track app-wide VPN and Taildrop state.
|
||||||
|
// This must be application-scoped because Tailscale may be enabled, disabled, or used for
|
||||||
|
// file transfers (Taildrop) outside the activity lifecycle.
|
||||||
|
//
|
||||||
|
// Responsibilities:
|
||||||
|
// - Track VPN preparation state (e.g., whether permission has been granted) and activity state
|
||||||
|
// - Monitor incoming Taildrop file transfers
|
||||||
|
// - Coordinate prompts for Taildrop directory selection if not yet configured
|
||||||
|
class AppViewModel(application: Application, private val taildropPrompt: Flow<Unit>) :
|
||||||
|
AndroidViewModel(application) {
|
||||||
|
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
|
||||||
|
// if the user has previously consented to the VPN application. This is used to determine whether
|
||||||
|
// a VPN permission launcher needs to be shown.
|
||||||
|
val _vpnPrepared = MutableStateFlow(false)
|
||||||
|
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
|
||||||
|
// Whether a VPN interface has been established. This is set by net.updateTUN upon
|
||||||
|
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
|
||||||
|
val _vpnActive = MutableStateFlow(false)
|
||||||
|
val vpnActive: StateFlow<Boolean> = _vpnActive
|
||||||
|
// Select Taildrop directory
|
||||||
|
var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
|
||||||
|
private val _triggerDirectoryPicker = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
val triggerDirectoryPicker: SharedFlow<Unit> = _triggerDirectoryPicker
|
||||||
|
val TAG = "AppViewModel"
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeIncomingTaildrop()
|
||||||
|
prepareVpn()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeIncomingTaildrop() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
taildropPrompt.collect {
|
||||||
|
TSLog.d(TAG, "Taildrop event received, checking directory")
|
||||||
|
checkIfTaildropDirectorySelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestDirectoryPicker() {
|
||||||
|
_triggerDirectoryPicker.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareVpn() {
|
||||||
|
// Check if the user has granted permission yet.
|
||||||
|
if (!vpnPrepared.value) {
|
||||||
|
val vpnIntent = VpnService.prepare(getApplication())
|
||||||
|
if (vpnIntent != null) {
|
||||||
|
setVpnPrepared(false)
|
||||||
|
Log.d(TAG, "VpnService.prepare returned non-null intent")
|
||||||
|
} else {
|
||||||
|
setVpnPrepared(true)
|
||||||
|
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkIfTaildropDirectorySelected() {
|
||||||
|
val app = App.get()
|
||||||
|
val storedUri = app.getStoredDirectoryUri()
|
||||||
|
if (ShareFileHelper.hasValidTaildropDir()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val documentFile = storedUri?.let { DocumentFile.fromTreeUri(app, it) }
|
||||||
|
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
|
||||||
|
TSLog.d(
|
||||||
|
"MainViewModel",
|
||||||
|
"Stored directory URI is invalid or inaccessible; launching directory picker.")
|
||||||
|
viewModelScope.launch { requestDirectoryPicker() }
|
||||||
|
} else {
|
||||||
|
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVpnActive(isActive: Boolean) {
|
||||||
|
_vpnActive.value = isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVpnPrepared(isPrepared: Boolean) {
|
||||||
|
_vpnPrepared.value = isPrepared
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.tailscale.ipn.TaildropDirectoryStore
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class PermissionsViewModel : ViewModel() {
|
||||||
|
private val _currentDir =
|
||||||
|
MutableStateFlow<String?>(TaildropDirectoryStore.loadSavedDir()?.toString())
|
||||||
|
val currentDir: StateFlow<String?> = _currentDir
|
||||||
|
|
||||||
|
fun refreshCurrentDir() {
|
||||||
|
val newUri = TaildropDirectoryStore.loadSavedDir()?.toString()
|
||||||
|
TSLog.d("PermissionsViewModel", "refreshCurrentDir: $newUri")
|
||||||
|
_currentDir.value = newUri
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,322 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.tailscale.ipn.TaildropDirectoryStore
|
||||||
|
import com.tailscale.ipn.ui.util.InputStreamAdapter
|
||||||
|
import com.tailscale.ipn.ui.util.OutputStreamAdapter
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import libtailscale.Libtailscale
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class SafFile(val fd: Int, val uri: String)
|
||||||
|
|
||||||
|
object ShareFileHelper : libtailscale.ShareFileHelper {
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var app: libtailscale.Application? = null
|
||||||
|
private var savedUri: String? = null
|
||||||
|
private var scope: CoroutineScope? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
this.app = app
|
||||||
|
savedUri = uri
|
||||||
|
scope = appScope
|
||||||
|
Libtailscale.setShareFileHelper(this)
|
||||||
|
TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A simple data class that holds a SAF OutputStream along with its URI.
|
||||||
|
data class SafStream(val uri: String, val stream: OutputStream)
|
||||||
|
|
||||||
|
val taildropPrompt = MutableSharedFlow<Unit>(replay = 1)
|
||||||
|
|
||||||
|
fun observeTaildropPrompt(): Flow<Unit> = taildropPrompt
|
||||||
|
|
||||||
|
@Volatile private var directoryReady: CompletableDeferred<Unit>? = null
|
||||||
|
|
||||||
|
fun hasValidTaildropDir(): Boolean {
|
||||||
|
val uri = TaildropDirectoryStore.loadSavedDir()
|
||||||
|
if (uri == null) return false
|
||||||
|
|
||||||
|
// Only SAF tree URIs are supported
|
||||||
|
if (uri.scheme != "content") {
|
||||||
|
TSLog.w("ShareFileHelper", "Invalid URI scheme for taildrop dir: ${uri.scheme}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = appContext ?: return false
|
||||||
|
val docFile = DocumentFile.fromTreeUri(context, uri)
|
||||||
|
|
||||||
|
if (docFile == null || !docFile.exists() || !docFile.canWrite()) {
|
||||||
|
TSLog.w("ShareFileHelper", "Stored taildrop URI is invalid or inaccessible: $uri")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun waitUntilTaildropDirReady() {
|
||||||
|
if (!hasValidTaildropDir()) {
|
||||||
|
if (directoryReady?.isActive != true) {
|
||||||
|
directoryReady = CompletableDeferred()
|
||||||
|
scope?.launch { taildropPrompt.emit(Unit) }
|
||||||
|
}
|
||||||
|
directoryReady?.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyDirectoryReady() {
|
||||||
|
directoryReady?.takeIf { !it.isCompleted }?.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A helper function that opens or creates a SafStream for a given file.
|
||||||
|
private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> {
|
||||||
|
val context = appContext ?: return "" to null
|
||||||
|
val dirUri = savedUri ?: return "" to null
|
||||||
|
val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null
|
||||||
|
val file =
|
||||||
|
dir.findFile(fileName)
|
||||||
|
?: dir.createFile("application/octet-stream", fileName)
|
||||||
|
?: return "" to null
|
||||||
|
val os = context.contentResolver.openOutputStream(file.uri, "rw")
|
||||||
|
return file.uri.toString() to os
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> {
|
||||||
|
val ctx = appContext ?: throw IOException("App context not initialized")
|
||||||
|
val dirUri = savedUri ?: throw IOException("No directory URI")
|
||||||
|
val dir =
|
||||||
|
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
|
||||||
|
?: throw IOException("Invalid tree URI: $dirUri")
|
||||||
|
val file =
|
||||||
|
dir.findFile(fileName)
|
||||||
|
?: dir.createFile("application/octet-stream", fileName)
|
||||||
|
?: throw IOException("Failed to create file: $fileName")
|
||||||
|
val pfd =
|
||||||
|
ctx.contentResolver.openFileDescriptor(file.uri, "rw")
|
||||||
|
?: throw IOException("Failed to open file descriptor for ${file.uri}")
|
||||||
|
val fos = FileOutputStream(pfd.fileDescriptor)
|
||||||
|
if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0)
|
||||||
|
return file.uri.toString() to SeekableOutputStream(fos, pfd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val currentUri = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream {
|
||||||
|
runBlocking { waitUntilTaildropDirReady() }
|
||||||
|
val (uri, stream) = openWriterFD(fileName, offset)
|
||||||
|
if (stream == null) {
|
||||||
|
throw IOException("Failed to open file writer for $fileName")
|
||||||
|
}
|
||||||
|
currentUri[fileName] = uri
|
||||||
|
return OutputStreamAdapter(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun getFileURI(fileName: String): String {
|
||||||
|
runBlocking { waitUntilTaildropDirReady() }
|
||||||
|
currentUri[fileName]?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val ctx = appContext ?: throw IOException("App context not initialized")
|
||||||
|
val dirStr = savedUri ?: throw IOException("No saved directory URI")
|
||||||
|
val dir =
|
||||||
|
DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr))
|
||||||
|
?: throw IOException("Invalid tree URI: $dirStr")
|
||||||
|
val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName")
|
||||||
|
val uri = file.uri.toString()
|
||||||
|
currentUri[fileName] = uri
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun renameFile(oldPath: String, targetName: String): String {
|
||||||
|
val ctx = appContext ?: throw IOException("not initialized")
|
||||||
|
val dirUri = savedUri ?: throw IOException("directory not set")
|
||||||
|
val srcUri = Uri.parse(oldPath)
|
||||||
|
val dir =
|
||||||
|
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
|
||||||
|
?: throw IOException("cannot open dir $dirUri")
|
||||||
|
|
||||||
|
var finalName = targetName
|
||||||
|
dir.findFile(finalName)?.let { existing ->
|
||||||
|
if (lengthOfUri(ctx, existing.uri) == 0L) {
|
||||||
|
existing.delete()
|
||||||
|
} else {
|
||||||
|
finalName = generateNewFilename(finalName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri ->
|
||||||
|
runCatching { ctx.contentResolver.delete(srcUri, null, null) }
|
||||||
|
cleanupPartials(dir, targetName)
|
||||||
|
return newUri.toString()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.w(
|
||||||
|
"renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val dest =
|
||||||
|
dir.createFile("application/octet-stream", finalName)
|
||||||
|
?: throw IOException("createFile failed for $finalName")
|
||||||
|
|
||||||
|
ctx.contentResolver.openInputStream(srcUri).use { inp ->
|
||||||
|
ctx.contentResolver.openOutputStream(dest.uri, "w").use { out ->
|
||||||
|
if (inp == null || out == null) {
|
||||||
|
dest.delete()
|
||||||
|
throw IOException("Unable to open output stream for URI: ${dest.uri}")
|
||||||
|
}
|
||||||
|
inp.copyTo(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.contentResolver.delete(srcUri, null, null)
|
||||||
|
cleanupPartials(dir, targetName)
|
||||||
|
return dest.uri.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lengthOfUri(ctx: Context, uri: Uri): Long =
|
||||||
|
ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 }
|
||||||
|
// delete any stray “.partial” files for this base name
|
||||||
|
private fun cleanupPartials(dir: DocumentFile, base: String) {
|
||||||
|
for (child in dir.listFiles()) {
|
||||||
|
val n = child.name ?: continue
|
||||||
|
if (n.endsWith(".partial") && n.contains(base, ignoreCase = false)) {
|
||||||
|
child.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun deleteFile(uri: String) {
|
||||||
|
runBlocking { waitUntilTaildropDirReady() }
|
||||||
|
val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
|
||||||
|
val uri = Uri.parse(uri)
|
||||||
|
val doc =
|
||||||
|
DocumentFile.fromSingleUri(ctx, uri)
|
||||||
|
?: throw IOException("DeleteFile: cannot resolve URI $uri")
|
||||||
|
if (!doc.delete()) {
|
||||||
|
throw IOException("DeleteFile: delete() returned false for $uri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun getFileInfo(fileName: String): String {
|
||||||
|
val context = appContext ?: throw IOException("app context not initialized")
|
||||||
|
val dirUri = savedUri ?: throw IOException("SAF URI not initialized")
|
||||||
|
val dir =
|
||||||
|
DocumentFile.fromTreeUri(context, Uri.parse(dirUri))
|
||||||
|
?: throw IOException("could not resolve SAF root")
|
||||||
|
val file =
|
||||||
|
dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory")
|
||||||
|
val name = file.name ?: throw IOException("file name missing for $fileName")
|
||||||
|
val size = file.length()
|
||||||
|
val modTime = file.lastModified()
|
||||||
|
return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonEscape(s: String): String {
|
||||||
|
return JSONObject.quote(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateNewFilename(filename: String): String {
|
||||||
|
val dotIndex = filename.lastIndexOf('.')
|
||||||
|
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
|
||||||
|
val extension = if (dotIndex != -1) filename.substring(dotIndex) else ""
|
||||||
|
val uuid = UUID.randomUUID()
|
||||||
|
return "$baseName-$uuid$extension"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun listPartialFiles(suffix: String): Array<String> {
|
||||||
|
val context = appContext ?: return emptyArray()
|
||||||
|
val rootUri = savedUri ?: return emptyArray()
|
||||||
|
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray()
|
||||||
|
return dir.listFiles()
|
||||||
|
.filter { it.name?.endsWith(suffix) == true }
|
||||||
|
.mapNotNull { it.name }
|
||||||
|
.toTypedArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun listFilesJSON(suffix: String): String {
|
||||||
|
val list = listPartialFiles(suffix)
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
throw IOException("no files found matching suffix \"$suffix\"")
|
||||||
|
}
|
||||||
|
return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun openFileReader(name: String): libtailscale.InputStream {
|
||||||
|
val context = appContext ?: throw IOException("app context not initialized")
|
||||||
|
val rootUri = savedUri ?: throw IOException("SAF URI not initialized")
|
||||||
|
val dir =
|
||||||
|
DocumentFile.fromTreeUri(context, Uri.parse(rootUri))
|
||||||
|
?: throw IOException("could not open SAF root")
|
||||||
|
val suffix = name.substringAfterLast('.', ".$name")
|
||||||
|
val file =
|
||||||
|
dir.listFiles().firstOrNull {
|
||||||
|
val fname = it.name ?: return@firstOrNull false
|
||||||
|
fname.endsWith(suffix, ignoreCase = false)
|
||||||
|
} ?: throw IOException("no file ending with \"$suffix\" in SAF directory")
|
||||||
|
val inStream =
|
||||||
|
context.contentResolver.openInputStream(file.uri)
|
||||||
|
?: throw IOException("openInputStream returned null for ${file.uri}")
|
||||||
|
return InputStreamAdapter(inStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUri(uri: String) {
|
||||||
|
savedUri = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SeekableOutputStream(
|
||||||
|
private val fos: FileOutputStream,
|
||||||
|
private val pfd: ParcelFileDescriptor
|
||||||
|
) : OutputStream() {
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
override fun write(b: Int) = fos.write(b)
|
||||||
|
|
||||||
|
override fun write(b: ByteArray) = fos.write(b)
|
||||||
|
|
||||||
|
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||||
|
fos.write(b, off, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (!closed) {
|
||||||
|
closed = true
|
||||||
|
try {
|
||||||
|
fos.flush()
|
||||||
|
fos.fd.sync() // blocks until data + metadata are durable
|
||||||
|
} finally {
|
||||||
|
fos.close()
|
||||||
|
pfd.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun flush() = fos.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:height="24dp"
|
||||||
|
android:width="24dp"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:tint="#000000">
|
||||||
|
|
||||||
|
<!-- Folder outline -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z" />
|
||||||
|
|
||||||
|
<!-- Flipped arrow, shifted downward by 1dp (in viewport units) -->
|
||||||
|
<group
|
||||||
|
android:translateY="2">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M16,10.99l-1.41,-1.41L13,11.16V7h-2v4.16L9.41,9.58 8,10.99 12.01,15 16,10.99z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2zM16,17L8,17v-6c0,-2.48 1.51,-4.5 4,-4.5s4,2.02 4,4.5v6z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@ -1 +1 @@
|
|||||||
982da8f24fa0504f2214f24b0d68b2febd5983f8
|
aa85d1541af0921f830f053f29d91971fa5838f6
|
||||||
|
|||||||
@ -0,0 +1,100 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package libtailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/feature/taildrop"
|
||||||
|
)
|
||||||
|
|
||||||
|
// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper.
|
||||||
|
type androidFileOps struct {
|
||||||
|
helper ShareFileHelper
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ taildrop.FileOps = (*androidFileOps)(nil)
|
||||||
|
|
||||||
|
func newAndroidFileOps(helper ShareFileHelper) *androidFileOps {
|
||||||
|
return &androidFileOps{helper: helper}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ops *androidFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) {
|
||||||
|
wc, err := ops.helper.OpenFileWriter(name, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
uri, err := ops.helper.GetFileURI(name)
|
||||||
|
if err != nil {
|
||||||
|
wc.Close()
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return wc, uri, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ops *androidFileOps) Remove(baseName string) error {
|
||||||
|
uri, err := ops.helper.GetFileURI(baseName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ops.helper.DeleteFile(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ops *androidFileOps) Rename(oldPath, newName string) (string, error) {
|
||||||
|
return ops.helper.RenameFile(oldPath, newName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ops *androidFileOps) ListFiles() ([]string, error) {
|
||||||
|
namesJSON, err := ops.helper.ListFilesJSON("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
if err := json.Unmarshal([]byte(namesJSON), &names); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) {
|
||||||
|
in, err := ops.helper.OpenFileReader(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return adaptInputStream(in), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) {
|
||||||
|
infoJSON, err := ops.helper.GetFileInfo(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var fi androidFileInfo
|
||||||
|
if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &fi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type androidFileInfoJSON struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ModTime int64 `json:"modTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type androidFileInfo struct {
|
||||||
|
data androidFileInfoJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// compile-time check
|
||||||
|
var _ os.FileInfo = (*androidFileInfo)(nil)
|
||||||
|
|
||||||
|
func (fi *androidFileInfo) Name() string { return fi.data.Name }
|
||||||
|
func (fi *androidFileInfo) Size() int64 { return fi.data.Size }
|
||||||
|
func (fi *androidFileInfo) Mode() os.FileMode { return 0o600 }
|
||||||
|
func (fi *androidFileInfo) ModTime() time.Time { return time.UnixMilli(fi.data.ModTime) }
|
||||||
|
func (fi *androidFileInfo) IsDir() bool { return false }
|
||||||
|
func (fi *androidFileInfo) Sys() any { return nil }
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package libtailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adaptInputStream wraps an [InputStream] into an [io.ReadCloser].
|
||||||
|
// It launches a goroutine to stream reads into a pipe.
|
||||||
|
func adaptInputStream(in InputStream) io.ReadCloser {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r, w := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
defer w.Close()
|
||||||
|
for {
|
||||||
|
b, err := in.Read()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error reading from inputstream: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := w.Write(b); err != nil {
|
||||||
|
log.Printf("error writing to pipe: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return r
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue