mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-17 21:05:48 -04:00
Merge pull request #2008 from aliasvault/2006-copy-totp-to-clipboard-after-autofill-on-ios-and-android
This commit is contained in:
@@ -75,6 +75,13 @@
|
||||
android:theme="@style/zxing_CaptureTheme"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<!-- Autofill Fill Activity (transparent; launched via Dataset.setAuthentication on selection) -->
|
||||
<activity
|
||||
android:name=".autofill.AutofillFillActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@style/Theme.App.NoDisplay" />
|
||||
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package net.aliasvault.app.autofill
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import android.service.autofill.Dataset
|
||||
import android.util.Log
|
||||
import android.view.autofill.AutofillId
|
||||
import android.view.autofill.AutofillManager
|
||||
import android.widget.RemoteViews
|
||||
import net.aliasvault.app.R
|
||||
import net.aliasvault.app.autofill.models.FieldType
|
||||
import net.aliasvault.app.autofill.utils.AutofillFieldMapper
|
||||
import net.aliasvault.app.utils.TotpGenerator
|
||||
import net.aliasvault.app.vaultstore.VaultStore
|
||||
|
||||
/**
|
||||
* Transparent activity launched by the OS when the user picks an autofill
|
||||
* credential row (wired via `Dataset.setAuthentication(IntentSender)`).
|
||||
* Optionally copies the item's current TOTP code to the clipboard when
|
||||
* [EXTRA_COPY_TOTP] is set, then builds the fill `Dataset` from the item's
|
||||
* stored values and returns it via `AutofillManager.EXTRA_AUTHENTICATION_RESULT`.
|
||||
*/
|
||||
class AutofillFillActivity : Activity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AliasVaultAutofill"
|
||||
|
||||
/** Intent extra for the vault item ID whose credentials should be filled. */
|
||||
const val EXTRA_ITEM_ID = "net.aliasvault.app.autofill.EXTRA_ITEM_ID"
|
||||
|
||||
/** Intent extra holding the parceled `AutofillId`s for the target form fields. */
|
||||
const val EXTRA_AUTOFILL_IDS = "net.aliasvault.app.autofill.EXTRA_AUTOFILL_IDS"
|
||||
|
||||
/** Intent extra holding the `FieldType` ordinals matching `EXTRA_AUTOFILL_IDS` one-to-one. */
|
||||
const val EXTRA_FIELD_TYPES = "net.aliasvault.app.autofill.EXTRA_FIELD_TYPES"
|
||||
|
||||
/** Intent extra controlling whether the item's current TOTP code is copied to the clipboard. */
|
||||
const val EXTRA_COPY_TOTP = "net.aliasvault.app.autofill.EXTRA_COPY_TOTP"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
try {
|
||||
val itemId = intent.getStringExtra(EXTRA_ITEM_ID)
|
||||
val autofillIds = parseAutofillIds(intent)
|
||||
val fieldTypeOrdinals = intent.getIntArrayExtra(EXTRA_FIELD_TYPES)
|
||||
val copyTotp = intent.getBooleanExtra(EXTRA_COPY_TOTP, false)
|
||||
|
||||
if (itemId == null || autofillIds == null || fieldTypeOrdinals == null ||
|
||||
autofillIds.size != fieldTypeOrdinals.size
|
||||
) {
|
||||
Log.w(TAG, "AutofillFillActivity: missing or mismatched extras, finishing")
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val store = VaultStore.getExistingInstance()
|
||||
if (store == null || !store.isVaultUnlocked()) {
|
||||
Log.w(TAG, "AutofillFillActivity: vault not available, finishing")
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val item = store.getAllItems().firstOrNull { it.id.toString().equals(itemId, ignoreCase = true) }
|
||||
if (item == null) {
|
||||
Log.w(TAG, "AutofillFillActivity: item not found, finishing")
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
if (copyTotp) {
|
||||
tryCopyTotpToClipboard(store, itemId)
|
||||
}
|
||||
|
||||
val fields = pairFields(autofillIds, fieldTypeOrdinals)
|
||||
// Presentation passed to the inner Dataset.Builder is never displayed
|
||||
// — the OS already showed the outer dataset's presentation in the
|
||||
// picker — but the legacy constructor requires a valid RemoteViews.
|
||||
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_icon)
|
||||
val builder = Dataset.Builder(presentation)
|
||||
AutofillFieldMapper.applyItem(builder, item, fields)
|
||||
|
||||
val resultIntent = Intent().apply {
|
||||
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, builder.build())
|
||||
}
|
||||
setResult(RESULT_OK, resultIntent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "AutofillFillActivity error", e)
|
||||
setResult(RESULT_CANCELED)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun parseAutofillIds(intent: Intent): Array<AutofillId>? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayExtra(EXTRA_AUTOFILL_IDS, AutofillId::class.java)
|
||||
} else {
|
||||
intent.getParcelableArrayExtra(EXTRA_AUTOFILL_IDS)
|
||||
?.mapNotNull { it as? AutofillId }
|
||||
?.toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pairFields(
|
||||
autofillIds: Array<AutofillId>,
|
||||
fieldTypeOrdinals: IntArray,
|
||||
): List<Pair<AutofillId, FieldType>> {
|
||||
val types = FieldType.values()
|
||||
return autofillIds.mapIndexed { i, id ->
|
||||
id to (types.getOrNull(fieldTypeOrdinals[i]) ?: FieldType.UNKNOWN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryCopyTotpToClipboard(store: VaultStore, itemId: String) {
|
||||
val secret = store.getTotpSecretForItem(itemId) ?: return
|
||||
val code = TotpGenerator.generateCode(secret) ?: return
|
||||
if (code.isEmpty()) return
|
||||
|
||||
try {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("AliasVault", code)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val extras = PersistableBundle().apply {
|
||||
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
|
||||
}
|
||||
clip.description.extras = extras
|
||||
}
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Log.d(TAG, "TOTP code copied to clipboard during autofill")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to copy TOTP code to clipboard", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import android.view.autofill.AutofillValue
|
||||
import android.widget.RemoteViews
|
||||
import net.aliasvault.app.MainActivity
|
||||
import net.aliasvault.app.R
|
||||
import net.aliasvault.app.autofill.models.FieldType
|
||||
import net.aliasvault.app.autofill.utils.AutofillFieldMapper
|
||||
import net.aliasvault.app.autofill.utils.FieldFinder
|
||||
import net.aliasvault.app.autofill.utils.ImageUtils
|
||||
import net.aliasvault.app.autofill.utils.RustItemMatcher
|
||||
@@ -176,6 +176,7 @@ class AutofillService : AutofillService() {
|
||||
// Add debug dataset if enabled in settings
|
||||
val sharedPreferences = getSharedPreferences("AliasVaultPrefs", android.content.Context.MODE_PRIVATE)
|
||||
val showSearchText = sharedPreferences.getBoolean("autofill_show_search_text", false)
|
||||
val copyTotpOnFill = sharedPreferences.getBoolean("autofill_copy_totp_on_fill", true)
|
||||
if (showSearchText) {
|
||||
responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown"))
|
||||
}
|
||||
@@ -190,9 +191,12 @@ class AutofillService : AutofillService() {
|
||||
} else {
|
||||
// If there are matches, add them to the dataset
|
||||
for (item in filteredItems) {
|
||||
responseBuilder.addDataset(
|
||||
createItemDataset(fieldFinder, item),
|
||||
val dataset = createItemDataset(
|
||||
fieldFinder = fieldFinder,
|
||||
item = item,
|
||||
copyTotpOnSelect = copyTotpOnFill && item.hasTotp,
|
||||
)
|
||||
responseBuilder.addDataset(dataset)
|
||||
}
|
||||
|
||||
// Add "Open app" option at the bottom (when search text is not shown and there are matches)
|
||||
@@ -245,124 +249,70 @@ class AutofillService : AutofillService() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dataset from an item.
|
||||
* @param fieldFinder The field finder
|
||||
* @param item The item
|
||||
* @return The dataset
|
||||
* Build the picker Dataset for an item. Sets presentation (label + icon),
|
||||
* stages placeholder values via [AutofillFieldMapper], and wires
|
||||
* `Dataset.setAuthentication` to [AutofillFillActivity], passing
|
||||
* [copyTotpOnSelect] through so the activity copies the TOTP code on
|
||||
* selection when requested. The OS discards the placeholder values and
|
||||
* substitutes the dataset returned by the activity.
|
||||
*/
|
||||
private fun createItemDataset(fieldFinder: FieldFinder, item: Item): Dataset {
|
||||
// Always use icon layout (will show logo or placeholder icon)
|
||||
val layoutId = R.layout.autofill_dataset_item_icon
|
||||
private fun createItemDataset(
|
||||
fieldFinder: FieldFinder,
|
||||
item: Item,
|
||||
copyTotpOnSelect: Boolean,
|
||||
): Dataset {
|
||||
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_icon)
|
||||
val builder = Dataset.Builder(presentation)
|
||||
|
||||
// Create presentation for this item using our custom layout
|
||||
val presentation = RemoteViews(packageName, layoutId)
|
||||
|
||||
val dataSetBuilder = Dataset.Builder(presentation)
|
||||
|
||||
// Add autofill values for all fields
|
||||
var presentationDisplayValue = item.name
|
||||
var hasSetValue = false
|
||||
for (field in fieldFinder.autofillableFields) {
|
||||
val fieldType = field.second
|
||||
when (fieldType) {
|
||||
FieldType.PASSWORD -> {
|
||||
if (!item.password.isNullOrEmpty()) {
|
||||
dataSetBuilder.setValue(
|
||||
field.first,
|
||||
AutofillValue.forText(item.password as CharSequence),
|
||||
)
|
||||
hasSetValue = true
|
||||
}
|
||||
}
|
||||
FieldType.EMAIL -> {
|
||||
if (!item.email.isNullOrEmpty()) {
|
||||
dataSetBuilder.setValue(
|
||||
field.first,
|
||||
AutofillValue.forText(item.email),
|
||||
)
|
||||
hasSetValue = true
|
||||
presentationDisplayValue += " (${item.email})"
|
||||
} else if (!item.username.isNullOrEmpty()) {
|
||||
dataSetBuilder.setValue(
|
||||
field.first,
|
||||
AutofillValue.forText(item.username),
|
||||
)
|
||||
hasSetValue = true
|
||||
presentationDisplayValue += " (${item.username})"
|
||||
}
|
||||
}
|
||||
FieldType.USERNAME -> {
|
||||
if (!item.username.isNullOrEmpty()) {
|
||||
dataSetBuilder.setValue(
|
||||
field.first,
|
||||
AutofillValue.forText(item.username),
|
||||
)
|
||||
hasSetValue = true
|
||||
presentationDisplayValue += " (${item.username})"
|
||||
} else if (!item.email.isNullOrEmpty()) {
|
||||
dataSetBuilder.setValue(
|
||||
field.first,
|
||||
AutofillValue.forText(item.email),
|
||||
)
|
||||
hasSetValue = true
|
||||
presentationDisplayValue += " (${item.email})"
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// For unknown field types, try both email and username
|
||||
if (!item.email.isNullOrEmpty()) {
|
||||
dataSetBuilder.setValue(
|
||||
field.first,
|
||||
AutofillValue.forText(item.email),
|
||||
)
|
||||
hasSetValue = true
|
||||
presentationDisplayValue += " (${item.email})"
|
||||
} else if (!item.username.isNullOrEmpty()) {
|
||||
dataSetBuilder.setValue(
|
||||
field.first,
|
||||
AutofillValue.forText(item.username),
|
||||
)
|
||||
hasSetValue = true
|
||||
presentationDisplayValue += " (${item.username})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no value was set, this shouldn't happen now since we filter items
|
||||
// but keep as safety measure
|
||||
if (!hasSetValue && fieldFinder.autofillableFields.isNotEmpty()) {
|
||||
val applyResult = AutofillFieldMapper.applyItem(builder, item, fieldFinder.autofillableFields)
|
||||
if (!applyResult.hasValue && fieldFinder.autofillableFields.isNotEmpty()) {
|
||||
Log.w(TAG, "Item ${item.name} has no autofillable data - this should have been filtered")
|
||||
dataSetBuilder.setValue(
|
||||
builder.setValue(
|
||||
fieldFinder.autofillableFields.first().first,
|
||||
AutofillValue.forText(""),
|
||||
)
|
||||
}
|
||||
|
||||
// Set the display value of the dropdown item.
|
||||
presentation.setTextViewText(
|
||||
R.id.text,
|
||||
presentationDisplayValue,
|
||||
)
|
||||
val displayValue = if (applyResult.labelSuffix != null) {
|
||||
"${item.name} (${applyResult.labelSuffix})"
|
||||
} else {
|
||||
item.name
|
||||
}
|
||||
presentation.setTextViewText(R.id.text, displayValue)
|
||||
|
||||
// Set the logo if available, otherwise use placeholder icon
|
||||
val logoBytes = item.logo
|
||||
val bitmap = if (logoBytes != null) {
|
||||
ImageUtils.bytesToBitmap(logoBytes)
|
||||
} else {
|
||||
// Use placeholder key icon for Login/Alias items
|
||||
ItemTypeIcon.getIcon(
|
||||
context = this@AutofillService,
|
||||
itemType = ItemTypeIcon.ItemType.LOGIN,
|
||||
size = 96,
|
||||
)
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
presentation.setImageViewBitmap(R.id.icon, bitmap)
|
||||
}
|
||||
|
||||
return dataSetBuilder.build()
|
||||
val autofillIds = fieldFinder.autofillableFields.map { it.first }.toTypedArray()
|
||||
val fieldTypeOrdinals = IntArray(fieldFinder.autofillableFields.size) { i ->
|
||||
fieldFinder.autofillableFields[i].second.ordinal
|
||||
}
|
||||
val authIntent = Intent(this, AutofillFillActivity::class.java).apply {
|
||||
putExtra(AutofillFillActivity.EXTRA_ITEM_ID, item.id.toString().uppercase())
|
||||
putExtra(AutofillFillActivity.EXTRA_AUTOFILL_IDS, autofillIds)
|
||||
putExtra(AutofillFillActivity.EXTRA_FIELD_TYPES, fieldTypeOrdinals)
|
||||
putExtra(AutofillFillActivity.EXTRA_COPY_TOTP, copyTotpOnSelect)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
item.id.hashCode(),
|
||||
authIntent,
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
)
|
||||
builder.setAuthentication(pendingIntent.intentSender)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package net.aliasvault.app.autofill.utils
|
||||
|
||||
import android.service.autofill.Dataset
|
||||
import android.view.autofill.AutofillId
|
||||
import android.view.autofill.AutofillValue
|
||||
import net.aliasvault.app.autofill.models.FieldType
|
||||
import net.aliasvault.app.vaultstore.models.Item
|
||||
|
||||
/**
|
||||
* Maps a vault [Item] onto the detected autofill fields, setting values on a
|
||||
* `Dataset.Builder` and reporting which value was used as the picker label
|
||||
* suffix.
|
||||
*/
|
||||
object AutofillFieldMapper {
|
||||
/**
|
||||
* Result of applying an item to a dataset builder.
|
||||
*
|
||||
* @property hasValue True if at least one field received a value.
|
||||
* @property labelSuffix First non-password value that was set (email or
|
||||
* username), suitable for appending to the picker row label, or null if
|
||||
* only the password field was set.
|
||||
*/
|
||||
data class ApplyResult(val hasValue: Boolean, val labelSuffix: String?)
|
||||
|
||||
/**
|
||||
* Set autofill values on [builder] for each of [fields] using the
|
||||
* corresponding data from [item]. Empty values are skipped.
|
||||
*/
|
||||
fun applyItem(
|
||||
builder: Dataset.Builder,
|
||||
item: Item,
|
||||
fields: List<Pair<AutofillId, FieldType>>,
|
||||
): ApplyResult {
|
||||
var hasValue = false
|
||||
var labelSuffix: String? = null
|
||||
|
||||
for ((autofillId, fieldType) in fields) {
|
||||
val value = pickValue(item, fieldType) ?: continue
|
||||
builder.setValue(autofillId, AutofillValue.forText(value))
|
||||
hasValue = true
|
||||
if (labelSuffix == null && fieldType != FieldType.PASSWORD) {
|
||||
labelSuffix = value
|
||||
}
|
||||
}
|
||||
|
||||
return ApplyResult(hasValue, labelSuffix)
|
||||
}
|
||||
|
||||
private fun pickValue(item: Item, fieldType: FieldType): String? = when (fieldType) {
|
||||
FieldType.PASSWORD -> item.password.takeUnless { it.isNullOrEmpty() }
|
||||
FieldType.EMAIL ->
|
||||
item.email.takeUnless { it.isNullOrEmpty() }
|
||||
?: item.username.takeUnless { it.isNullOrEmpty() }
|
||||
FieldType.USERNAME ->
|
||||
item.username.takeUnless { it.isNullOrEmpty() }
|
||||
?: item.email.takeUnless { it.isNullOrEmpty() }
|
||||
FieldType.UNKNOWN ->
|
||||
item.email.takeUnless { it.isNullOrEmpty() }
|
||||
?: item.username.takeUnless { it.isNullOrEmpty() }
|
||||
}
|
||||
}
|
||||
@@ -896,6 +896,57 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the autofill copy-TOTP-on-fill setting.
|
||||
* Defaults to true when not yet set.
|
||||
* @param promise The promise to resolve with boolean result
|
||||
*/
|
||||
@ReactMethod
|
||||
override fun getAutofillCopyTotpOnFill(promise: Promise) {
|
||||
try {
|
||||
val sharedPreferences = reactApplicationContext.getSharedPreferences("AliasVaultPrefs", android.content.Context.MODE_PRIVATE)
|
||||
val enabled = sharedPreferences.getBoolean("autofill_copy_totp_on_fill", true)
|
||||
promise.resolve(enabled)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting autofill copy-TOTP-on-fill setting", e)
|
||||
promise.reject("ERR_GET_AUTOFILL_SETTING", "Failed to get autofill copy-TOTP-on-fill setting: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the autofill copy-TOTP-on-fill setting.
|
||||
* @param enabled Whether to copy TOTP code to clipboard on autofill
|
||||
* @param promise The promise to resolve
|
||||
*/
|
||||
@ReactMethod
|
||||
override fun setAutofillCopyTotpOnFill(enabled: Boolean, promise: Promise) {
|
||||
try {
|
||||
val sharedPreferences = reactApplicationContext.getSharedPreferences("AliasVaultPrefs", android.content.Context.MODE_PRIVATE)
|
||||
sharedPreferences.edit().putBoolean("autofill_copy_totp_on_fill", enabled).apply()
|
||||
promise.resolve(null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error setting autofill copy-TOTP-on-fill setting", e)
|
||||
promise.reject("ERR_SET_AUTOFILL_SETTING", "Failed to set autofill copy-TOTP-on-fill setting: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TOTP code from a Base32-encoded secret.
|
||||
* Delegates to the shared Kotlin TotpGenerator so the JS layer can reuse
|
||||
* the same RFC 6238 implementation as the autofill service. Returns null
|
||||
* for invalid secrets.
|
||||
*/
|
||||
@ReactMethod
|
||||
override fun generateTotpCode(secret: String, promise: Promise) {
|
||||
try {
|
||||
val code = net.aliasvault.app.utils.TotpGenerator.generateCode(secret)
|
||||
promise.resolve(code)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error generating TOTP code", e)
|
||||
promise.reject("ERR_TOTP_GENERATE", "Failed to generate TOTP code: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current fragment activity.
|
||||
* @return The fragment activity
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package net.aliasvault.app.utils
|
||||
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.experimental.and
|
||||
|
||||
/**
|
||||
* RFC 6238 TOTP generator.
|
||||
*/
|
||||
object TotpGenerator {
|
||||
private const val TAG = "TotpGenerator"
|
||||
private const val DEFAULT_PERIOD = 30
|
||||
private const val DEFAULT_DIGITS = 6
|
||||
|
||||
/**
|
||||
* Generate the current TOTP code for a Base32-encoded secret.
|
||||
* Returns null when the secret is invalid or HMAC fails.
|
||||
*/
|
||||
fun generateCode(
|
||||
secret: String,
|
||||
timeSeconds: Long = System.currentTimeMillis() / 1000L,
|
||||
period: Int = DEFAULT_PERIOD,
|
||||
digits: Int = DEFAULT_DIGITS,
|
||||
): String? {
|
||||
val secretBytes = base32Decode(secret) ?: return null
|
||||
if (secretBytes.isEmpty()) return null
|
||||
|
||||
val counter = timeSeconds / period
|
||||
return generateHotp(secretBytes, counter, digits)
|
||||
}
|
||||
|
||||
private fun generateHotp(secret: ByteArray, counter: Long, digits: Int): String? {
|
||||
return try {
|
||||
val counterBytes = ByteBuffer.allocate(Long.SIZE_BYTES).putLong(counter).array()
|
||||
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(SecretKeySpec(secret, "HmacSHA1"))
|
||||
val hash = mac.doFinal(counterBytes)
|
||||
|
||||
// Dynamic truncation per RFC 4226
|
||||
val offset = (hash[hash.size - 1] and 0x0F).toInt()
|
||||
val binary = ((hash[offset].toInt() and 0x7F) shl 24) or
|
||||
((hash[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((hash[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(hash[offset + 3].toInt() and 0xFF)
|
||||
|
||||
val mod = pow10(digits)
|
||||
val code = binary % mod
|
||||
code.toString().padStart(digits, '0')
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "TOTP HMAC generation failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun pow10(n: Int): Int {
|
||||
var result = 1
|
||||
repeat(n) { result *= 10 }
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Base32 (RFC 4648) string to bytes. Tolerates lowercase, spaces,
|
||||
* and trailing padding ('='). Returns null on invalid characters.
|
||||
*/
|
||||
private fun base32Decode(input: String): ByteArray? {
|
||||
val cleaned = input.uppercase().replace(" ", "").replace("=", "")
|
||||
if (cleaned.isEmpty()) return ByteArray(0)
|
||||
|
||||
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
val output = ByteArray((cleaned.length * 5) / 8)
|
||||
var buffer = 0
|
||||
var bitsLeft = 0
|
||||
var index = 0
|
||||
|
||||
for (ch in cleaned) {
|
||||
val value = alphabet.indexOf(ch)
|
||||
if (value < 0) return null
|
||||
buffer = (buffer shl 5) or value
|
||||
bitsLeft += 5
|
||||
if (bitsLeft >= 8) {
|
||||
output[index++] = ((buffer shr (bitsLeft - 8)) and 0xFF).toByte()
|
||||
bitsLeft -= 8
|
||||
}
|
||||
}
|
||||
|
||||
return output.copyOfRange(0, index)
|
||||
}
|
||||
}
|
||||
@@ -441,6 +441,23 @@ class VaultStore(
|
||||
return itemRepository.getAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first non-deleted TOTP secret for an item, or null when none exists.
|
||||
* Used by the autofill service to copy a credential's current TOTP code to
|
||||
* the clipboard at fill time.
|
||||
*/
|
||||
fun getTotpSecretForItem(itemId: String): String? {
|
||||
if (!database.isVaultUnlocked()) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
itemRepository.getTotpSecretForItem(itemId)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Error getting TOTP secret for item", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to get all items using only the cached encryption key.
|
||||
*/
|
||||
|
||||
@@ -389,6 +389,22 @@ class ItemRepository(database: VaultDatabase) : BaseRepository(database) {
|
||||
return (results.firstOrNull()?.get("count") as? Long)?.toInt() ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first non-deleted TOTP secret for an item, or null when there is none.
|
||||
* Used by the autofill service to copy the current TOTP code to the clipboard
|
||||
* when the user selects a credential to fill.
|
||||
*
|
||||
* @param itemId The UUID of the item.
|
||||
* @return The Base32 secret key string, or null.
|
||||
*/
|
||||
fun getTotpSecretForItem(itemId: String): String? {
|
||||
val results = executeQuery(
|
||||
"SELECT SecretKey FROM TotpCodes WHERE ItemId = ? AND IsDeleted = 0 ORDER BY Name ASC LIMIT 1",
|
||||
arrayOf(itemId.uppercase()),
|
||||
)
|
||||
return results.firstOrNull()?.get("SecretKey") as? String
|
||||
}
|
||||
|
||||
// MARK: - Write Operations
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,4 +52,5 @@
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">@android:color/transparent</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||
</style>
|
||||
<style name="Theme.App.NoDisplay" parent="@android:style/Theme.NoDisplay" />
|
||||
</resources>
|
||||
@@ -20,18 +20,23 @@ export default function AndroidAutofillScreen() : React.ReactNode {
|
||||
const { markAutofillConfigured, shouldShowAutofillReminder } = useAuth();
|
||||
const [advancedOptionsExpanded, setAdvancedOptionsExpanded] = useState(false);
|
||||
const [showSearchText, setShowSearchText] = useState(false);
|
||||
const [copyTotpOnFill, setCopyTotpOnFill] = useState(true);
|
||||
|
||||
/**
|
||||
* Load the show search text setting on mount.
|
||||
* Load native autofill toggle settings on mount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Load the show search text setting.
|
||||
* Read the persisted autofill toggle settings.
|
||||
*/
|
||||
const loadSettings = async () : Promise<void> => {
|
||||
try {
|
||||
const value = await NativeVaultManager.getAutofillShowSearchText();
|
||||
setShowSearchText(value);
|
||||
const [searchText, totpOnFill] = await Promise.all([
|
||||
NativeVaultManager.getAutofillShowSearchText(),
|
||||
NativeVaultManager.getAutofillCopyTotpOnFill(),
|
||||
]);
|
||||
setShowSearchText(searchText);
|
||||
setCopyTotpOnFill(totpOnFill);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load autofill settings:', err);
|
||||
}
|
||||
@@ -78,6 +83,18 @@ export default function AndroidAutofillScreen() : React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle toggling the copy-TOTP-on-fill setting.
|
||||
*/
|
||||
const handleToggleCopyTotpOnFill = async (value: boolean) : Promise<void> => {
|
||||
try {
|
||||
await NativeVaultManager.setAutofillCopyTotpOnFill(value);
|
||||
setCopyTotpOnFill(value);
|
||||
} catch (err) {
|
||||
console.warn('Failed to update copy-TOTP-on-fill setting:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
advancedOptionsContainer: {
|
||||
marginTop: 16,
|
||||
@@ -267,8 +284,8 @@ export default function AndroidAutofillScreen() : React.ReactNode {
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
{t('settings.androidAutofillSettings.step2')}
|
||||
</ThemedText>
|
||||
<View style={styles.buttonContainer}>
|
||||
{shouldShowAutofillReminder && (
|
||||
{shouldShowAutofillReminder && (
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={handleAlreadyConfigured}
|
||||
@@ -277,8 +294,8 @@ export default function AndroidAutofillScreen() : React.ReactNode {
|
||||
{t('settings.androidAutofillSettings.alreadyConfigured')}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.advancedOptionsContainer}>
|
||||
@@ -287,7 +304,7 @@ export default function AndroidAutofillScreen() : React.ReactNode {
|
||||
onPress={() => setAdvancedOptionsExpanded(!advancedOptionsExpanded)}
|
||||
>
|
||||
<ThemedText style={styles.advancedOptionsTitle}>
|
||||
{t('settings.androidAutofillSettings.advancedOptions')}
|
||||
{t('settings.advancedOptions')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.chevron}>
|
||||
{advancedOptionsExpanded ? '▼' : '▶'}
|
||||
@@ -296,6 +313,22 @@ export default function AndroidAutofillScreen() : React.ReactNode {
|
||||
|
||||
{advancedOptionsExpanded && (
|
||||
<View>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingRowText}>
|
||||
<ThemedText style={styles.settingRowTitle}>
|
||||
{t('settings.copyTotpOnFill')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.settingRowDescription}>
|
||||
{t('settings.copyTotpOnFillDescription')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Switch
|
||||
value={copyTotpOnFill}
|
||||
onValueChange={handleToggleCopyTotpOnFill}
|
||||
trackColor={{ false: colors.accentBackground, true: colors.primary }}
|
||||
thumbColor={colors.primarySurfaceText}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingRowText}>
|
||||
<ThemedText style={styles.settingRowTitle}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, View, TouchableOpacity } from 'react-native';
|
||||
import { StyleSheet, View, TouchableOpacity, Switch } from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
@@ -17,6 +18,26 @@ export default function IosAutofillScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const { markAutofillConfigured, shouldShowAutofillReminder } = useAuth();
|
||||
const [advancedOptionsExpanded, setAdvancedOptionsExpanded] = useState(false);
|
||||
const [copyTotpOnFill, setCopyTotpOnFill] = useState(true);
|
||||
|
||||
/**
|
||||
* Load native autofill toggle settings on mount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Read the persisted copy-TOTP-on-fill setting.
|
||||
*/
|
||||
const loadSettings = async () : Promise<void> => {
|
||||
try {
|
||||
const value = await NativeVaultManager.getAutofillCopyTotpOnFill();
|
||||
setCopyTotpOnFill(value);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load autofill settings:', err);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle the configure press.
|
||||
@@ -38,11 +59,42 @@ export default function IosAutofillScreen() : React.ReactNode {
|
||||
router.back();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle toggling the copy-TOTP-on-fill setting.
|
||||
*/
|
||||
const handleToggleCopyTotpOnFill = async (value: boolean) : Promise<void> => {
|
||||
try {
|
||||
await NativeVaultManager.setAutofillCopyTotpOnFill(value);
|
||||
setCopyTotpOnFill(value);
|
||||
} catch (err) {
|
||||
console.warn('Failed to update copy-TOTP-on-fill setting:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
advancedOptionsContainer: {
|
||||
marginTop: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
advancedOptionsTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
advancedOptionsToggleHeader: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
buttonContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
chevron: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 20,
|
||||
},
|
||||
configureButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
@@ -100,11 +152,30 @@ export default function IosAutofillScreen() : React.ReactNode {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
warningText: {
|
||||
settingRow: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
padding: 16,
|
||||
},
|
||||
settingRowDescription: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginTop: 4,
|
||||
},
|
||||
settingRowText: {
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
settingRowTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 8,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -148,11 +219,8 @@ export default function IosAutofillScreen() : React.ReactNode {
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
{t('settings.iosAutofillSettings.step5')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.warningText}>
|
||||
{t('settings.iosAutofillSettings.warningText')}
|
||||
</ThemedText>
|
||||
<View style={styles.buttonContainer}>
|
||||
{shouldShowAutofillReminder && (
|
||||
{shouldShowAutofillReminder && (
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={handleAlreadyConfigured}
|
||||
@@ -161,10 +229,45 @@ export default function IosAutofillScreen() : React.ReactNode {
|
||||
{t('settings.iosAutofillSettings.alreadyConfigured')}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.advancedOptionsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.advancedOptionsToggleHeader}
|
||||
onPress={() => setAdvancedOptionsExpanded(!advancedOptionsExpanded)}
|
||||
>
|
||||
<ThemedText style={styles.advancedOptionsTitle}>
|
||||
{t('settings.advancedOptions')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.chevron}>
|
||||
{advancedOptionsExpanded ? '▼' : '▶'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{advancedOptionsExpanded && (
|
||||
<View>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingRowText}>
|
||||
<ThemedText style={styles.settingRowTitle}>
|
||||
{t('settings.copyTotpOnFill')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.settingRowDescription}>
|
||||
{t('settings.copyTotpOnFillDescription')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Switch
|
||||
value={copyTotpOnFill}
|
||||
onValueChange={handleToggleCopyTotpOnFill}
|
||||
trackColor={{ false: colors.accentBackground, true: colors.primary }}
|
||||
thumbColor={colors.primarySurfaceText}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ThemedScrollView>
|
||||
</ThemedContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ export function ItemCard({ item, onItemDelete, showFolderPath = false }: ItemCar
|
||||
const totpCodes = await dbContext.sqliteClient.settings.getTotpCodesForItem(item.Id);
|
||||
const activeTotp = totpCodes.find(tc => !tc.IsDeleted);
|
||||
if (activeTotp) {
|
||||
const code = generateTotpCode(activeTotp.SecretKey);
|
||||
const code = await generateTotpCode(activeTotp.SecretKey);
|
||||
if (code) {
|
||||
await copyToClipboard(code);
|
||||
if (Platform.OS === 'ios') {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { View, StyleSheet, TextInput, Modal, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
@@ -12,6 +11,7 @@ import { useDialog } from '@/context/DialogContext';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
import type { TotpCode } from '@/utils/dist/core/models/vault';
|
||||
import { parseOtpAuthUri } from '@/utils/TotpUtility';
|
||||
|
||||
type TotpFormData = {
|
||||
name: string;
|
||||
@@ -104,28 +104,21 @@ export const TotpEditor: React.FC<TotpEditorProps> = ({
|
||||
|
||||
if (scannedData) {
|
||||
// Parse the otpauth:// URL
|
||||
try {
|
||||
const uri = OTPAuth.URI.parse(scannedData);
|
||||
if (uri instanceof OTPAuth.TOTP) {
|
||||
const secretKey = uri.secret.base32;
|
||||
const name = uri.label || 'Authenticator';
|
||||
const parsed = parseOtpAuthUri(scannedData);
|
||||
if (parsed && parsed.type === 'totp') {
|
||||
const secretKey = parsed.secret.replace(/\s/g, '').replace(/=+$/, '');
|
||||
const name = parsed.label || 'Authenticator';
|
||||
|
||||
// Create new TOTP code immediately
|
||||
const newTotpCode: TotpCode = {
|
||||
Id: crypto.randomUUID().toUpperCase(),
|
||||
Name: name,
|
||||
SecretKey: secretKey,
|
||||
ItemId: '' // Will be set when saving the item
|
||||
};
|
||||
const newTotpCode: TotpCode = {
|
||||
Id: crypto.randomUUID().toUpperCase(),
|
||||
Name: name,
|
||||
SecretKey: secretKey,
|
||||
ItemId: '' // Will be set when saving the item
|
||||
};
|
||||
|
||||
// Add to the list
|
||||
const updatedTotpCodes = [...totpCodes, newTotpCode];
|
||||
onTotpCodesChange(updatedTotpCodes);
|
||||
} else {
|
||||
showAlert(t('common.error'), t('totp.errors.scanFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing TOTP QR code:', error);
|
||||
const updatedTotpCodes = [...totpCodes, newTotpCode];
|
||||
onTotpCodesChange(updatedTotpCodes);
|
||||
} else {
|
||||
showAlert(t('common.error'), t('totp.errors.scanFailed'));
|
||||
}
|
||||
}
|
||||
@@ -160,18 +153,15 @@ export const TotpEditor: React.FC<TotpEditorProps> = ({
|
||||
|
||||
// Check if it's a TOTP URI
|
||||
if (secretKey.toLowerCase().startsWith('otpauth://totp/')) {
|
||||
try {
|
||||
const uri = OTPAuth.URI.parse(secretKey);
|
||||
if (uri instanceof OTPAuth.TOTP) {
|
||||
secretKey = uri.secret.base32;
|
||||
// If name is empty, use the label from the URI
|
||||
if (!name && uri.label) {
|
||||
name = uri.label;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const parsed = parseOtpAuthUri(secretKey);
|
||||
if (!parsed || parsed.type !== 'totp') {
|
||||
throw new Error(t('totp.errors.invalidSecretKey'));
|
||||
}
|
||||
secretKey = parsed.secret;
|
||||
// If name is empty, use the label from the URI
|
||||
if (!name && parsed.label) {
|
||||
name = parsed.label;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove spaces from the secret key
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { generateTotpCode } from '@/utils/TotpUtility';
|
||||
@@ -43,16 +42,10 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ item }) : React.ReactN
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Get the remaining seconds.
|
||||
* Get the remaining seconds in the current TOTP window.
|
||||
*/
|
||||
const getRemainingSeconds = (step = 30): number => {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret: 'dummy',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: step
|
||||
});
|
||||
return totp.period - (Math.floor(Date.now() / 1000) % totp.period);
|
||||
return step - (Math.floor(Date.now() / 1000) % step);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -109,34 +102,38 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ item }) : React.ReactN
|
||||
}, [item, dbContext?.sqliteClient]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
/**
|
||||
* Update the totp codes.
|
||||
* Generate codes for all current TOTP entries via the native bridge and
|
||||
* push them into state. Falls back to "Error" only when no previous code
|
||||
* exists for that entry, so the display doesn't flicker when a single
|
||||
* tick fails.
|
||||
*/
|
||||
const updateTotpCodes = (prevCodes: Record<string, string>): Record<string, string> => {
|
||||
const newCodes: Record<string, string> = {};
|
||||
totpCodes.forEach(code => {
|
||||
const generatedCode = generateTotpCode(code.SecretKey);
|
||||
if (generatedCode) {
|
||||
newCodes[code.Id] = generatedCode;
|
||||
} else {
|
||||
newCodes[code.Id] = prevCodes[code.Id] ?? 'Error';
|
||||
const refreshCodes = async () : Promise<void> => {
|
||||
const results = await Promise.all(
|
||||
totpCodes.map(async (code) => ({
|
||||
id: code.Id,
|
||||
value: await generateTotpCode(code.SecretKey),
|
||||
}))
|
||||
);
|
||||
if (cancelled) return;
|
||||
setCurrentCodes(prev => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const { id, value } of results) {
|
||||
next[id] = value || prev[id] || 'Error';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return newCodes;
|
||||
};
|
||||
|
||||
const initialCodes: Record<string, string> = {};
|
||||
totpCodes.forEach(code => {
|
||||
const codeStr = generateTotpCode(code.SecretKey);
|
||||
initialCodes[code.Id] = codeStr || 'Error';
|
||||
});
|
||||
setCurrentCodes(initialCodes);
|
||||
refreshCodes();
|
||||
const intervalId = setInterval(refreshCodes, 1000);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
setCurrentCodes(updateTotpCodes);
|
||||
}, 1000);
|
||||
|
||||
return () : void => clearInterval(intervalId);
|
||||
return () : void => {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [totpCodes]);
|
||||
|
||||
if (totpCodes.length === 0) {
|
||||
|
||||
@@ -136,8 +136,7 @@
|
||||
"step4": "4. Enable \"AliasVault\"",
|
||||
"step5": "5. Disable other password providers (e.g. \"iCloud Passwords\") to avoid conflicts",
|
||||
"openIosSettings": "Open iOS Settings",
|
||||
"alreadyConfigured": "I already configured it",
|
||||
"warningText": "Note: You'll need to authenticate with Face ID/Touch ID or your device passcode when using autofill."
|
||||
"alreadyConfigured": "I already configured it"
|
||||
},
|
||||
"androidAutofillSettings": {
|
||||
"warningTitle": "⚠️ Experimental Feature",
|
||||
@@ -151,10 +150,12 @@
|
||||
"buttonTip": "If the button above doesn't work it might be blocked because of security settings. You can manually go to Android Settings → General Management → Passwords and autofill.",
|
||||
"step2": "2. Some apps, e.g. Google Chrome, may require manual configuration in their settings to allow third-party autofill apps. However, most apps should work with autofill by default.",
|
||||
"alreadyConfigured": "I already configured it",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"showSearchText": "Show search text",
|
||||
"showSearchTextDescription": "Include the text AliasVault receives from Android that it uses to search for a matching credential"
|
||||
},
|
||||
"advancedOptions": "Advanced Options",
|
||||
"copyTotpOnFill": "Copy TOTP code on autofill",
|
||||
"copyTotpOnFillDescription": "When you autofill a credential with 2FA, automatically copy its TOTP code to the clipboard so you can paste it into the next field.",
|
||||
"vaultUnlock": "Vault Unlock Method",
|
||||
"autoLock": "Auto-lock Timeout",
|
||||
"clipboardClear": "Clear Clipboard",
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
import VaultStoreKit
|
||||
import VaultUI
|
||||
import VaultModels
|
||||
import VaultUtils
|
||||
|
||||
/**
|
||||
* Credential-specific functionality for CredentialProviderViewController
|
||||
@@ -11,7 +12,6 @@ import VaultModels
|
||||
extension CredentialProviderViewController: CredentialProviderDelegate {
|
||||
|
||||
// MARK: - CredentialProviderDelegate Implementation
|
||||
|
||||
func setupCredentialView(vaultStore: VaultStore, serviceUrl: String?) throws -> UIViewController {
|
||||
// Create the ViewModel with injected behaviors
|
||||
let viewModel = CredentialProviderViewModel(
|
||||
@@ -124,6 +124,18 @@ extension CredentialProviderViewController: CredentialProviderDelegate {
|
||||
Thread.sleep(forTimeInterval: minimumDuration - elapsed)
|
||||
}
|
||||
|
||||
// If the credential has a TOTP secret and the user has the
|
||||
// copy-on-fill setting enabled (default), put the current
|
||||
// TOTP code on the clipboard so they can paste it into the
|
||||
// 2FA field after the autofill completes.
|
||||
if matchingCredential.hasTotp,
|
||||
let secret = matchingCredential.totpSecret,
|
||||
AutofillSettings.shouldCopyTotpOnFill,
|
||||
let code = TotpGenerator.generateCode(secret: secret),
|
||||
!code.isEmpty {
|
||||
UIPasteboard.general.string = code
|
||||
}
|
||||
|
||||
// Use the identifier that matches the credential identity
|
||||
let identifier = request.credentialIdentity.user
|
||||
let passwordCredential = ASPasswordCredential(
|
||||
|
||||
@@ -151,10 +151,22 @@
|
||||
[vaultManager setAutofillShowSearchText:showSearchText resolver:resolve rejecter:reject];
|
||||
}
|
||||
|
||||
- (void)getAutofillCopyTotpOnFill:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
||||
[vaultManager getAutofillCopyTotpOnFill:resolve rejecter:reject];
|
||||
}
|
||||
|
||||
- (void)setAutofillCopyTotpOnFill:(BOOL)enabled resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
||||
[vaultManager setAutofillCopyTotpOnFill:enabled resolver:resolve rejecter:reject];
|
||||
}
|
||||
|
||||
- (void)copyToClipboardWithExpiration:(NSString *)text expirationSeconds:(double)expirationSeconds localOnly:(BOOL)localOnly resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
||||
[vaultManager copyToClipboardWithExpiration:text expirationSeconds:expirationSeconds localOnly:localOnly resolver:resolve rejecter:reject];
|
||||
}
|
||||
|
||||
- (void)generateTotpCode:(NSString *)secret resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
||||
[vaultManager generateTotpCode:secret resolver:resolve rejecter:reject];
|
||||
}
|
||||
|
||||
// MARK: - Android-specific methods (stubs for iOS)
|
||||
|
||||
- (void)isIgnoringBatteryOptimizations:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
||||
|
||||
@@ -5,6 +5,7 @@ import VaultStoreKit
|
||||
import VaultModels
|
||||
import SwiftUI
|
||||
import VaultUI
|
||||
import VaultUtils
|
||||
import AVFoundation
|
||||
import RustCoreFramework
|
||||
import AuthenticationServices
|
||||
@@ -392,6 +393,29 @@ public class VaultManager: NSObject {
|
||||
resolve(nil)
|
||||
}
|
||||
|
||||
@objc
|
||||
func getAutofillCopyTotpOnFill(_ resolve: @escaping RCTPromiseResolveBlock,
|
||||
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
||||
resolve(AutofillSettings.shouldCopyTotpOnFill)
|
||||
}
|
||||
|
||||
@objc
|
||||
func setAutofillCopyTotpOnFill(_ enabled: Bool,
|
||||
resolver resolve: @escaping RCTPromiseResolveBlock,
|
||||
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
||||
AutofillSettings.shouldCopyTotpOnFill = enabled
|
||||
resolve(nil)
|
||||
}
|
||||
|
||||
@objc
|
||||
func generateTotpCode(_ secret: String,
|
||||
resolver resolve: @escaping RCTPromiseResolveBlock,
|
||||
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
||||
// Returns nil for invalid secrets; the JS side treats null as "code unavailable".
|
||||
let code = TotpGenerator.generateCode(secret: secret)
|
||||
resolve(code)
|
||||
}
|
||||
|
||||
@objc
|
||||
func copyToClipboardWithExpiration(_ text: String,
|
||||
expirationSeconds: Double,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import Foundation
|
||||
import VaultModels
|
||||
|
||||
/// Constants used for userDefaults keys and other things.
|
||||
public struct VaultConstants {
|
||||
static let keychainService = "net.aliasvault.autofill"
|
||||
static let keychainAccessGroup = "group.net.aliasvault.autofill"
|
||||
static let userDefaultsSuite = "group.net.aliasvault.autofill"
|
||||
|
||||
static let vaultMetadataKey = "aliasvault_vault_metadata"
|
||||
static let encryptionKeyKey = "aliasvault_encryption_key"
|
||||
static let encryptedDbFileName = "encrypted_db.sqlite"
|
||||
static let authMethodsKey = "aliasvault_auth_methods"
|
||||
static let autoLockTimeoutKey = "aliasvault_auto_lock_timeout"
|
||||
static let encryptionKeyDerivationParamsKey = "aliasvault_encryption_key_derivation_params"
|
||||
static let usernameKey = "aliasvault_username"
|
||||
static let offlineModeKey = "aliasvault_offline_mode"
|
||||
static let pinEnabledKey = "aliasvault_pin_enabled"
|
||||
static let serverVersionKey = "aliasvault_server_version"
|
||||
|
||||
// Sync state keys (for offline sync and race detection)
|
||||
static let isDirtyKey = "aliasvault_is_dirty"
|
||||
static let mutationSequenceKey = "aliasvault_mutation_sequence"
|
||||
static let isSyncingKey = "aliasvault_is_syncing"
|
||||
|
||||
static let defaultAutoLockTimeout: Int = 3600 // 1 hour in seconds
|
||||
|
||||
// Trash retention. Soft-deleted items stay in the recycle bin for this many
|
||||
// days before the Rust pruner permanently removes them on the next sync.
|
||||
// This value is declared in other places as well, make sure to update them
|
||||
// when updating this value.
|
||||
static let trashRetentionDays: Int = 30
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import VaultUtils
|
||||
|
||||
/**
|
||||
* Native Swift WebAPI service for making HTTP requests to the AliasVault server.
|
||||
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import LocalAuthentication
|
||||
import Security
|
||||
import VaultModels
|
||||
import VaultUtils
|
||||
|
||||
/// Extension for the VaultStore class to handle authentication methods
|
||||
extension VaultStore {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import VaultUtils
|
||||
|
||||
/// Extension for the VaultStore class to handle cache management
|
||||
extension VaultStore {
|
||||
|
||||
@@ -3,6 +3,7 @@ import CryptoKit
|
||||
import LocalAuthentication
|
||||
import Security
|
||||
import SignalArgon2
|
||||
import VaultUtils
|
||||
|
||||
/// Extension for the VaultStore class to handle encryption/decryption
|
||||
extension VaultStore {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
import VaultUtils
|
||||
|
||||
/// Extension for the VaultStore class to handle database management
|
||||
extension VaultStore {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import VaultModels
|
||||
import VaultUtils
|
||||
|
||||
/// Extension for the VaultStore class to handle metadata management
|
||||
extension VaultStore {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import VaultModels
|
||||
import VaultUtils
|
||||
|
||||
/// Vault upload model that matches the API contract
|
||||
public struct VaultUpload: Codable {
|
||||
|
||||
@@ -3,6 +3,7 @@ import CryptoKit
|
||||
import Security
|
||||
import SignalArgon2
|
||||
import VaultModels
|
||||
import VaultUtils
|
||||
|
||||
/// Extension for the VaultStore class to handle PIN unlock functionality
|
||||
extension VaultStore {
|
||||
|
||||
@@ -5,6 +5,7 @@ import CryptoKit
|
||||
import CommonCrypto
|
||||
import Security
|
||||
import VaultModels
|
||||
import VaultUtils
|
||||
|
||||
/// This class is used to store and retrieve the encrypted AliasVault database and encryption key.
|
||||
/// It also handles executing queries against the SQLite database and biometric authentication.
|
||||
|
||||
@@ -8,12 +8,14 @@ private let locBundle = Bundle.vaultUI
|
||||
public struct AutofillCredentialCard: View {
|
||||
let credential: AutofillCredential
|
||||
let action: () -> Void
|
||||
let onCopy: () -> Void
|
||||
let onCopy: (String) -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var showCopyToast = false
|
||||
@State private var copyToastMessage = ""
|
||||
|
||||
public init(credential: AutofillCredential, action: @escaping () -> Void, onCopy: @escaping () -> Void) {
|
||||
public init(
|
||||
credential: AutofillCredential,
|
||||
action: @escaping () -> Void,
|
||||
onCopy: @escaping (String) -> Void = { _ in }
|
||||
) {
|
||||
self.credential = credential
|
||||
self.action = action
|
||||
self.onCopy = onCopy
|
||||
@@ -72,15 +74,14 @@ public struct AutofillCredentialCard: View {
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.contextMenu(menuItems: {
|
||||
// Copy actions only copy to the clipboard and show a toast — they
|
||||
// intentionally leave the autofill picker open so the user can
|
||||
// still pick a credential to fill afterwards (for example: copy
|
||||
// TOTP first, then tap to fill username/password).
|
||||
if let username = credential.username, !username.isEmpty {
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = username
|
||||
copyToastMessage = String(localized: "username_copied", bundle: locBundle)
|
||||
showCopyToast = true
|
||||
// Delay for 1 second before calling onCopy which dismisses the view
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
onCopy()
|
||||
}
|
||||
onCopy(String(localized: "username_copied", bundle: locBundle))
|
||||
}, label: {
|
||||
Label(String(localized: "copy_username", bundle: locBundle), systemImage: "person")
|
||||
})
|
||||
@@ -89,12 +90,7 @@ public struct AutofillCredentialCard: View {
|
||||
if let password = credential.password, !password.isEmpty {
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = password
|
||||
copyToastMessage = String(localized: "password_copied", bundle: locBundle)
|
||||
showCopyToast = true
|
||||
// Delay for 1 second before calling onCopy which dismisses the view
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
onCopy()
|
||||
}
|
||||
onCopy(String(localized: "password_copied", bundle: locBundle))
|
||||
}, label: {
|
||||
Label(String(localized: "copy_password", bundle: locBundle), systemImage: "key")
|
||||
})
|
||||
@@ -103,20 +99,28 @@ public struct AutofillCredentialCard: View {
|
||||
if let email = credential.email, !email.isEmpty {
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = email
|
||||
copyToastMessage = String(localized: "email_copied", bundle: locBundle)
|
||||
showCopyToast = true
|
||||
// Delay for 1 second before calling onCopy which dismisses the view
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
onCopy()
|
||||
}
|
||||
onCopy(String(localized: "email_copied", bundle: locBundle))
|
||||
}, label: {
|
||||
Label(String(localized: "copy_email", bundle: locBundle), systemImage: "envelope")
|
||||
})
|
||||
}
|
||||
|
||||
if credential.hasTotp,
|
||||
let secret = credential.totpSecret,
|
||||
let code = TotpGenerator.generateCode(secret: secret),
|
||||
!code.isEmpty {
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = code
|
||||
onCopy(String(localized: "totp_code_copied", bundle: locBundle))
|
||||
}, label: {
|
||||
Label(String(localized: "copy_totp_code", bundle: locBundle), systemImage: "number")
|
||||
})
|
||||
}
|
||||
|
||||
if (credential.username != nil && !credential.username!.isEmpty) ||
|
||||
(credential.password != nil && !credential.password!.isEmpty) ||
|
||||
(credential.email != nil && !credential.email!.isEmpty) {
|
||||
(credential.email != nil && !credential.email!.isEmpty) ||
|
||||
credential.hasTotp {
|
||||
Divider()
|
||||
}
|
||||
|
||||
@@ -136,29 +140,37 @@ public struct AutofillCredentialCard: View {
|
||||
Label(String(localized: "edit", bundle: locBundle), systemImage: "pencil")
|
||||
})
|
||||
})
|
||||
.overlay(
|
||||
Group {
|
||||
if showCopyToast {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(copyToastMessage)
|
||||
.padding()
|
||||
.background(Color.black.opacity(0.7))
|
||||
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
|
||||
.cornerRadius(8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.transition(.opacity)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
showCopyToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toast pill used for copy confirmations.
|
||||
public struct CopyToastView: View {
|
||||
public let message: String
|
||||
|
||||
public init(message: String) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.green)
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.18), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +200,6 @@ public func truncateText(_ text: String?, limit: Int) -> String {
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
),
|
||||
action: {},
|
||||
onCopy: {}
|
||||
action: {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public struct CredentialProviderView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var toastMessage: String?
|
||||
|
||||
public init(viewModel: CredentialProviderViewModel) {
|
||||
self._viewModel = ObservedObject(wrappedValue: viewModel)
|
||||
@@ -91,9 +92,7 @@ public struct CredentialProviderView: View {
|
||||
password: password
|
||||
)
|
||||
},
|
||||
onCopy: {
|
||||
viewModel.cancel()
|
||||
}
|
||||
onCopy: presentToast
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -163,6 +162,19 @@ public struct CredentialProviderView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if let message = toastMessage {
|
||||
CopyToastView(message: message)
|
||||
.padding(.bottom, 24)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: toastMessage)
|
||||
.task(id: toastMessage) {
|
||||
guard toastMessage != nil else { return }
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
toastMessage = nil
|
||||
}
|
||||
.task {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
await viewModel.loadCredentials()
|
||||
@@ -173,6 +185,12 @@ public struct CredentialProviderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the global copy-confirmation toast. Each call resets the
|
||||
/// auto-dismiss timer via `.task(id: toastMessage)` on the body.
|
||||
private func presentToast(_ message: String) {
|
||||
toastMessage = message
|
||||
}
|
||||
|
||||
/// Two-way binding that maps the optional pendingLinkSelection to a Bool
|
||||
/// for SwiftUI's `alert(_:isPresented:presenting:)` modifier. Setting the
|
||||
/// bound value to `false` clears the pending selection on the view-model.
|
||||
@@ -221,7 +239,7 @@ private struct AutofillCredentialCardWithSelection: View {
|
||||
let credential: AutofillCredential
|
||||
let isChoosingTextToInsert: Bool
|
||||
let onSelect: (String, String) -> Void
|
||||
let onCopy: () -> Void
|
||||
let onCopy: (String) -> Void
|
||||
|
||||
@State private var showSelectionSheet = false
|
||||
@State private var totpCode: String?
|
||||
@@ -239,6 +257,18 @@ private struct AutofillCredentialCardWithSelection: View {
|
||||
// For normal autofill, use the credential's identifier property
|
||||
let identifier = credential.identifier
|
||||
|
||||
// If the credential has a TOTP secret and the user has the
|
||||
// copy-on-fill setting enabled (default), put the current
|
||||
// TOTP code on the clipboard so they can paste it into the
|
||||
// 2FA field after the autofill completes.
|
||||
if credential.hasTotp,
|
||||
let secret = credential.totpSecret,
|
||||
AutofillSettings.shouldCopyTotpOnFill,
|
||||
let code = TotpGenerator.generateCode(secret: secret),
|
||||
!code.isEmpty {
|
||||
UIPasteboard.general.string = code
|
||||
}
|
||||
|
||||
// Fill both username and password immediately for normal autofill
|
||||
onSelect(identifier, credential.password ?? "")
|
||||
}
|
||||
|
||||
@@ -35,10 +35,12 @@
|
||||
"copy_username" = "Copy Username";
|
||||
"copy_password" = "Copy Password";
|
||||
"copy_email" = "Copy Email";
|
||||
"copy_totp_code" = "Copy TOTP Code";
|
||||
"view_details" = "View Details";
|
||||
"username_copied" = "Username copied";
|
||||
"password_copied" = "Password copied";
|
||||
"email_copied" = "Email copied";
|
||||
"totp_code_copied" = "TOTP code copied";
|
||||
"totp_code" = "TOTP Code";
|
||||
|
||||
/* Search bar */
|
||||
|
||||
28
apps/mobile-app/ios/VaultUtils/AutofillSettings.swift
Normal file
28
apps/mobile-app/ios/VaultUtils/AutofillSettings.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// Read/write access to autofill-related preferences shared between the main app and
|
||||
/// the iOS Autofill extension via the App Group UserDefaults suite.
|
||||
///
|
||||
/// All underlying identifiers (suite name + key names) come from `VaultConstants`
|
||||
/// so they are defined exactly once across the project.
|
||||
public enum AutofillSettings {
|
||||
private static var sharedDefaults: UserDefaults? {
|
||||
UserDefaults(suiteName: VaultConstants.userDefaultsSuite)
|
||||
}
|
||||
|
||||
/// Whether the autofill extension should copy a credential's current TOTP code to
|
||||
/// the clipboard when the user selects it for autofill.
|
||||
/// Defaults to `true` when the key has never been written.
|
||||
public static var shouldCopyTotpOnFill: Bool {
|
||||
get {
|
||||
guard let defaults = sharedDefaults else { return true }
|
||||
if defaults.object(forKey: VaultConstants.autofillCopyTotpOnFillKey) == nil {
|
||||
return true
|
||||
}
|
||||
return defaults.bool(forKey: VaultConstants.autofillCopyTotpOnFillKey)
|
||||
}
|
||||
set {
|
||||
sharedDefaults?.set(newValue, forKey: VaultConstants.autofillCopyTotpOnFillKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/mobile-app/ios/VaultUtils/VaultConstants.swift
Normal file
34
apps/mobile-app/ios/VaultUtils/VaultConstants.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Constants used for userDefaults keys, keychain identifiers, and other shared
|
||||
/// identifiers across the app, autofill extension, and shared frameworks.
|
||||
public struct VaultConstants {
|
||||
public static let keychainService = "net.aliasvault.autofill"
|
||||
public static let keychainAccessGroup = "group.net.aliasvault.autofill"
|
||||
public static let userDefaultsSuite = "group.net.aliasvault.autofill"
|
||||
|
||||
public static let vaultMetadataKey = "aliasvault_vault_metadata"
|
||||
public static let encryptionKeyKey = "aliasvault_encryption_key"
|
||||
public static let encryptedDbFileName = "encrypted_db.sqlite"
|
||||
public static let authMethodsKey = "aliasvault_auth_methods"
|
||||
public static let autoLockTimeoutKey = "aliasvault_auto_lock_timeout"
|
||||
public static let encryptionKeyDerivationParamsKey = "aliasvault_encryption_key_derivation_params"
|
||||
public static let usernameKey = "aliasvault_username"
|
||||
public static let offlineModeKey = "aliasvault_offline_mode"
|
||||
public static let pinEnabledKey = "aliasvault_pin_enabled"
|
||||
public static let serverVersionKey = "aliasvault_server_version"
|
||||
public static let autofillCopyTotpOnFillKey = "aliasvault_autofill_copy_totp_on_fill"
|
||||
|
||||
// Sync state keys (for offline sync and race detection)
|
||||
public static let isDirtyKey = "aliasvault_is_dirty"
|
||||
public static let mutationSequenceKey = "aliasvault_mutation_sequence"
|
||||
public static let isSyncingKey = "aliasvault_is_syncing"
|
||||
|
||||
public static let defaultAutoLockTimeout: Int = 3600 // 1 hour in seconds
|
||||
|
||||
// Trash retention. Soft-deleted items stay in the recycle bin for this many
|
||||
// days before the Rust pruner permanently removes them on the next sync.
|
||||
// This value is declared in other places as well, make sure to update them
|
||||
// when updating this value.
|
||||
public static let trashRetentionDays: Int = 30
|
||||
}
|
||||
25
apps/mobile-app/package-lock.json
generated
25
apps/mobile-app/package-lock.json
generated
@@ -36,7 +36,6 @@
|
||||
"fbemitter": "^3.0.0",
|
||||
"i18next": "^25.3.2",
|
||||
"lodash": "^4.18.1",
|
||||
"otpauth": "^9.4.0",
|
||||
"react": "19.0.0",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"react-i18next": "^15.6.0",
|
||||
@@ -3161,18 +3160,6 @@
|
||||
"@tybys/wasm-util": "^0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||
@@ -11804,18 +11791,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/otpauth": {
|
||||
"version": "9.4.1",
|
||||
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.4.1.tgz",
|
||||
"integrity": "sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/hectorm/otpauth?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"fbemitter": "^3.0.0",
|
||||
"i18next": "^25.3.2",
|
||||
"lodash": "^4.18.1",
|
||||
"otpauth": "^9.4.0",
|
||||
"react": "19.0.0",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"react-i18next": "^15.6.0",
|
||||
|
||||
@@ -67,10 +67,18 @@ export interface Spec extends TurboModule {
|
||||
openAutofillSettingsPage(): Promise<void>;
|
||||
getAutofillShowSearchText(): Promise<boolean>;
|
||||
setAutofillShowSearchText(showSearchText: boolean): Promise<void>;
|
||||
getAutofillCopyTotpOnFill(): Promise<boolean>;
|
||||
setAutofillCopyTotpOnFill(enabled: boolean): Promise<void>;
|
||||
|
||||
// Clipboard management
|
||||
copyToClipboardWithExpiration(text: string, expirationSeconds: number, localOnly: boolean): Promise<void>;
|
||||
|
||||
// TOTP code generation (RFC 6238, HMAC-SHA1, 6 digits, 30s period).
|
||||
// Delegates to the platform-native TOTP generator so iOS, Android and the
|
||||
// autofill extensions all share one implementation. Returns null when the
|
||||
// secret is invalid.
|
||||
generateTotpCode(secret: string): Promise<string | null>;
|
||||
|
||||
// Battery optimization management
|
||||
isIgnoringBatteryOptimizations(): Promise<boolean>;
|
||||
requestIgnoreBatteryOptimizations(): Promise<string>;
|
||||
|
||||
@@ -1,23 +1,89 @@
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
|
||||
/**
|
||||
* Generates the current TOTP code for the given secret key.
|
||||
* Uses standard TOTP settings: SHA1, 6 digits, 30-second period.
|
||||
*
|
||||
* Delegates to the platform-native TOTP generator (Swift on iOS, Kotlin on
|
||||
* Android) so the React Native layer, the iOS Autofill extension, and the
|
||||
* Android Autofill service all share one RFC 6238 implementation. Standard
|
||||
* settings: HMAC-SHA1, 6 digits, 30-second period.
|
||||
*
|
||||
* @param secretKey - Base32-encoded TOTP secret
|
||||
* @returns The current 6-digit TOTP code, or empty string on error
|
||||
*/
|
||||
export function generateTotpCode(secretKey: string): string {
|
||||
export async function generateTotpCode(secretKey: string): Promise<string> {
|
||||
try {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret: secretKey,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30
|
||||
});
|
||||
return totp.generate();
|
||||
const code = await NativeVaultManager.generateTotpCode(secretKey);
|
||||
return code ?? '';
|
||||
} catch (error) {
|
||||
console.error('Error generating TOTP code:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed `otpauth://` URI components.
|
||||
*/
|
||||
export type OtpAuthUri = {
|
||||
/** "totp" or "hotp" — only "totp" is supported elsewhere in the app. */
|
||||
type: 'totp' | 'hotp';
|
||||
/** URL-decoded path component, typically "Issuer:account". */
|
||||
label: string;
|
||||
/** Base32 secret from the `secret` query parameter. */
|
||||
secret: string;
|
||||
/** Optional `issuer` query parameter. */
|
||||
issuer?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse an `otpauth://` URI per
|
||||
* https://github.com/google/google-authenticator/wiki/Key-Uri-Format.
|
||||
*
|
||||
* Returns null when the input is not a valid `otpauth://` URI or is missing
|
||||
* a `secret` parameter. Does NOT validate the Base32 alphabet of the secret —
|
||||
* callers (e.g. `sanitizeSecretKey` in TotpEditor) handle that separately.
|
||||
*/
|
||||
export function parseOtpAuthUri(uri: string): OtpAuthUri | null {
|
||||
const trimmed = uri.trim();
|
||||
const prefix = 'otpauth://';
|
||||
if (trimmed.toLowerCase().slice(0, prefix.length) !== prefix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const afterScheme = trimmed.slice(prefix.length);
|
||||
const slashIdx = afterScheme.indexOf('/');
|
||||
if (slashIdx < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typeRaw = afterScheme.slice(0, slashIdx).toLowerCase();
|
||||
if (typeRaw !== 'totp' && typeRaw !== 'hotp') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = afterScheme.slice(slashIdx + 1);
|
||||
const queryIdx = rest.indexOf('?');
|
||||
const labelEncoded = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest;
|
||||
const queryString = queryIdx >= 0 ? rest.slice(queryIdx + 1) : '';
|
||||
|
||||
let label: string;
|
||||
try {
|
||||
label = decodeURIComponent(labelEncoded);
|
||||
} catch {
|
||||
label = labelEncoded;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(queryString);
|
||||
const secret = params.get('secret');
|
||||
if (!secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issuer = params.get('issuer');
|
||||
return {
|
||||
type: typeRaw,
|
||||
label,
|
||||
secret,
|
||||
...(issuer ? { issuer } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1833,6 +1833,11 @@ video {
|
||||
background-color: rgb(255 251 235 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-amber-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(245 158 11 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-black {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
|
||||
@@ -1858,6 +1863,11 @@ video {
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-emerald-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(16 185 129 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
||||
@@ -2017,6 +2027,11 @@ video {
|
||||
background-color: rgb(254 249 195 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-yellow-200 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-yellow-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 252 232 / var(--tw-bg-opacity));
|
||||
@@ -2027,21 +2042,6 @@ video {
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-amber-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(245 158 11 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-emerald-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(16 185 129 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-yellow-200 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-opacity-50 {
|
||||
--tw-bg-opacity: 0.5;
|
||||
}
|
||||
@@ -3313,16 +3313,16 @@ video {
|
||||
border-color: rgb(234 179 8 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:border-yellow-800:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(133 77 14 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:border-yellow-600:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(202 138 4 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:border-yellow-800:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(133 77 14 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-amber-800\/30:is(.dark *) {
|
||||
background-color: rgb(146 64 14 / 0.3);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user