From c42eca681ff9cae5be9ee7ac6d28bb3d9920c9b3 Mon Sep 17 00:00:00 2001 From: Parham Alvani Date: Thu, 16 Jul 2026 23:44:12 +0000 Subject: [PATCH] feat: resolve gopass:// password references Add support for gopass-style cross-secret password references: an entry whose password is `gopass://` now resolves at read time to the referenced entry's password, so a canonical credential need not be duplicated. Rotating the canonical entry updates every reference. - Detect references on PasswordEntry (isReference/referencePath). - PasswordReferenceResolver substitutes the referenced entry's password and, only when the origin lacks them, inherits its username/OTP. Resolution is recursive with cycle detection, a depth limit, and path-traversal guards. - Reuse the existing interactive unlock flow (passphrase/biometric/PIN) to decrypt referenced entries requiring a different key, via a reference-decrypt sink in BasePGPActivity; non-reference entries are unaffected. - Resolve references in the decrypt screen (editing still shows the raw gopass:// reference) and in autofill. --- .../data/crypto/PasswordReferenceResolver.kt | 180 ++++++++++++++++++ .../ui/autofill/AutofillDecryptActivity.kt | 28 ++- .../ui/crypto/BasePGPActivity.kt | 121 +++++++++++- .../ui/crypto/DecryptActivity.kt | 47 ++++- app/src/main/res/values/strings.xml | 1 + .../crypto/PasswordReferenceResolverTest.kt | 150 +++++++++++++++ .../data/passfile/PasswordEntry.kt | 22 +++ .../data/passfile/PasswordEntryTest.kt | 21 ++ 8 files changed, 561 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/data/crypto/PasswordReferenceResolver.kt create mode 100644 app/src/test/java/app/passwordstore/data/crypto/PasswordReferenceResolverTest.kt diff --git a/app/src/main/java/app/passwordstore/data/crypto/PasswordReferenceResolver.kt b/app/src/main/java/app/passwordstore/data/crypto/PasswordReferenceResolver.kt new file mode 100644 index 0000000000..386d610e8a --- /dev/null +++ b/app/src/main/java/app/passwordstore/data/crypto/PasswordReferenceResolver.kt @@ -0,0 +1,180 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.data.crypto + +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.passfile.isBlank +import app.passwordstore.data.passfile.joinToCharArray +import app.passwordstore.data.passfile.splitToCharArrayListAt +import app.passwordstore.data.passfile.startsWith +import app.passwordstore.util.totp.TotpFinder +import java.io.File +import javax.inject.Inject + +/** + * Resolves gopass-style `gopass://` cross-secret password references. + * + * A password entry whose password is `gopass://` does not hold a real password; instead the + * password of the entry at `` (relative to the store root) should be used. References resolve + * recursively (a target may itself be a reference) and circular references are rejected. + * + * The class is intentionally UI- and crypto-agnostic: decryption of the referenced file is supplied + * by the caller as the [decrypt] lambda, so the interactive unlock machinery (passphrase prompts, + * biometrics, key selection) lives in the activity layer while this class only orchestrates and + * merges. The [merge] step is pure and unit-testable. + */ +class PasswordReferenceResolver +@Inject +constructor(private val passwordEntryFactory: PasswordEntry.Factory) { + + sealed interface Result { + /** The fully-resolved plaintext, with the reference substituted by the target's password. */ + class Resolved(val plaintext: CharArray) : Result + + /** Resolution failed; [reason] explains why and the original entry should be shown as-is. */ + data class Unresolved(val reason: Reason) : Result + } + + enum class Reason { + /** A reference eventually points back at an already-visited entry. */ + CYCLE, + /** References nest deeper than [MAX_DEPTH]. */ + TOO_DEEP, + /** The referenced path is empty or escapes the store root. */ + INVALID_PATH, + /** The user aborted, or no key was available, while unlocking a referenced entry. */ + ABORTED, + } + + /** + * Resolves any reference in [plaintext]. + * + * @param plaintext the decrypted content of the origin entry (owned by the caller). + * @param repoRoot the password store root directory. + * @param originPath absolute path of the origin `.gpg` file, used to seed cycle detection. + * @param decrypt unlocks a referenced file, returning its plaintext or `null` if unavailable. + * @return [Result.Resolved] (its plaintext may be the same array when [plaintext] is not a + * reference) or [Result.Unresolved]. + */ + suspend fun resolve( + plaintext: CharArray, + repoRoot: File, + originPath: String, + decrypt: suspend (File) -> CharArray?, + ): Result { + val seed = runCatching { File(originPath).canonicalPath }.getOrDefault(originPath) + return resolveRec(plaintext, repoRoot, setOf(seed), 0, decrypt) + } + + private suspend fun resolveRec( + plaintext: CharArray, + repoRoot: File, + visited: Set, + depth: Int, + decrypt: suspend (File) -> CharArray?, + ): Result { + val entry = passwordEntryFactory.create(plaintext.copyOf()) + if (!entry.isReference()) { + entry.clear() + return Result.Resolved(plaintext) + } + val refPath = entry.referencePath() + entry.clear() + if (refPath.isNullOrBlank()) return Result.Unresolved(Reason.INVALID_PATH) + if (depth >= MAX_DEPTH) return Result.Unresolved(Reason.TOO_DEEP) + + val targetFile = File(repoRoot, "$refPath.gpg") + if (!targetFile.isInside(repoRoot)) return Result.Unresolved(Reason.INVALID_PATH) + val canonical = runCatching { targetFile.canonicalPath }.getOrDefault(targetFile.path) + if (canonical in visited) return Result.Unresolved(Reason.CYCLE) + + val targetPlaintext = decrypt(targetFile) ?: return Result.Unresolved(Reason.ABORTED) + val targetResolved = + resolveRec(targetPlaintext, repoRoot, visited + canonical, depth + 1, decrypt) + val targetPlain = + when (targetResolved) { + is Result.Resolved -> targetResolved.plaintext + is Result.Unresolved -> { + targetPlaintext.fill('\u0000') + return targetResolved + } + } + + val merged = merge(plaintext, targetPlain) + targetPlain.fill('\u0000') + if (!targetPlain.contentEquals(targetPlaintext)) targetPlaintext.fill('\u0000') + return Result.Resolved(merged) + } + + /** + * Produces the effective plaintext for a reference: [origin]'s password line is replaced with + * [target]'s password, and — only when [origin] does not define them itself — [target]'s username + * and TOTP are inherited. Any other fields defined by [origin] are preserved. Pure; does not read + * files or decrypt. + */ + fun merge(origin: CharArray, target: CharArray): CharArray { + val originEntry = passwordEntryFactory.create(origin.copyOf()) + val targetEntry = passwordEntryFactory.create(target.copyOf()) + + val lines = origin.splitToCharArrayListAt('\n').toMutableList() + replacePasswordLine(lines, targetEntry.password?.copyOf() ?: charArrayOf()) + + val targetUsername = targetEntry.username + if (originEntry.username == null && targetUsername != null) { + lines.add("username: ".toCharArray() + targetUsername) + } + if (!originEntry.hasTotp() && targetEntry.hasTotp()) { + findTotpLine(target)?.let { lines.add(it) } + } + + val result = lines.joinToCharArray('\n') ?: charArrayOf() + originEntry.clear() + targetEntry.clear() + return result + } + + /** + * Overwrites the password line of [lines] in place with [newPassword], mirroring + * [PasswordEntry]'s own password detection (first non-blank line unless it is a username/TOTP + * field, otherwise the first `password:`/`secret:`/`pass:` field). + */ + private fun replacePasswordLine(lines: MutableList, newPassword: CharArray) { + if (lines.isEmpty()) return + val fieldPrefixes = PasswordEntry.USERNAME_FIELDS + TotpFinder.TOTP_FIELDS + if (!lines[0].isBlank() && fieldPrefixes.none { lines[0].startsWith(it, ignoreCase = true) }) { + lines[0] = newPassword + return + } + for (i in lines.indices) { + if (lines[i].isBlank()) break + for (prefix in PasswordEntry.PASSWORD_FIELDS) { + if (lines[i].startsWith(prefix, ignoreCase = true)) { + lines[i] = "$prefix ".toCharArray() + newPassword + return + } + } + } + } + + private fun findTotpLine(plaintext: CharArray): CharArray? { + var found: CharArray? = null + for (line in plaintext.splitToCharArrayListAt('\n')) { + if (TotpFinder.TOTP_FIELDS.any { line.startsWith(it, ignoreCase = true) }) + found = line.copyOf() + } + return found + } + + private fun File.isInside(root: File): Boolean { + val rootPath = runCatching { root.canonicalPath }.getOrDefault(root.path) + val thisPath = runCatching { canonicalPath }.getOrDefault(path) + return thisPath == rootPath || thisPath.startsWith("$rootPath${File.separator}") + } + + companion object { + /** Maximum reference chain length before giving up (also bounds runaway recursion). */ + const val MAX_DEPTH: Int = 20 + } +} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt index 4a3344e0df..b265aea5b0 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -17,6 +17,7 @@ import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.crypto.errors.IncorrectPassphraseException import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException +import app.passwordstore.data.crypto.PasswordReferenceResolver import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.PasswordHistory @@ -47,6 +48,7 @@ import logcat.logcat class AutofillDecryptActivity : BasePGPActivity() { @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + @Inject lateinit var referenceResolver: PasswordReferenceResolver @PasswordHistory @Inject lateinit var passwordHistory: SharedPreferences private lateinit var filePath: String @@ -98,7 +100,12 @@ class AutofillDecryptActivity : BasePGPActivity() { lastResult.second.getOrThrow().wipe() val decryptedEntryChars = decryptedEntryBytes.toCharArray() decryptedEntryBytes.wipe() - val entry = passwordEntryFactory.create(decryptedEntryChars) + + // Resolve any gopass:// password reference to the target entry's credentials. + val effectiveChars = resolveReferences(decryptedEntryChars) + decryptedEntryChars.wipe() + + val entry = passwordEntryFactory.create(effectiveChars) entry.clearExtra() val directoryStructure = AutofillPreferences.directoryStructure(this) val credentials = @@ -182,6 +189,25 @@ class AutofillDecryptActivity : BasePGPActivity() { } } + /** + * If [plaintext] is a gopass-style `gopass://` reference, returns the effective content with the + * referenced entry's password (and, as a fallback, its username/OTP) substituted in; otherwise + * returns a copy of [plaintext]. On failure the original content is used unchanged. + */ + private suspend fun resolveReferences(plaintext: CharArray): CharArray { + val resolved = + referenceResolver.resolve( + plaintext = plaintext.copyOf(), + repoRoot = PasswordRepository.getRepositoryDirectory(), + originPath = filePath, + decrypt = { file -> decryptReferencedFile(file) }, + ) + return when (resolved) { + is PasswordReferenceResolver.Result.Resolved -> resolved.plaintext + is PasswordReferenceResolver.Result.Unresolved -> plaintext.copyOf() + } + } + companion object { private const val EXTRA_FILE_PATH = "app.passwordstore.autofill.oreo.EXTRA_FILE_PATH" diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index 04ba7ea643..5f3b58baa2 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -21,6 +21,7 @@ import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.errors.IncorrectPassphraseException import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.repo.PasswordRepository @@ -40,16 +41,20 @@ import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.isInsideRepository import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.extensions.substringBefore +import app.passwordstore.util.extensions.toCharArray import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.extensions.wipe import app.passwordstore.util.passkey.PasskeyCredential import app.passwordstore.util.settings.Constants import app.passwordstore.util.settings.PreferenceKeys import com.github.michaelbull.result.get +import com.github.michaelbull.result.getError +import com.github.michaelbull.result.getOrThrow import com.github.michaelbull.result.onErr import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import java.io.ByteArrayOutputStream import java.io.File import java.nio.CharBuffer import java.time.Instant @@ -57,6 +62,7 @@ import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import javax.inject.Inject +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -160,6 +166,17 @@ open class BasePGPActivity : AppCompatActivity() { @Inject lateinit var repository: CryptoRepository @Inject lateinit var dispatcherProvider: DispatcherProvider + /** + * State for [decryptReferencedFile]. When [activeReferenceDecrypt] is true the shared unlock + * machinery ([decrypt]/[askPassphrase]) delivers plaintext to [referenceDecryptSink] instead of + * the subclass's [decryptWithPassphrase], so a `gopass://` referenced entry can be unlocked + * interactively while reusing all passphrase/biometric/PIN handling. + */ + private var activeReferenceDecrypt = false + private var referenceDecryptPath = "" + private var referenceDecryptSink: (suspend (CharArray, String) -> Unit)? = null + private var referenceDecryptFailure: (() -> Unit)? = null + /** * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or * recent apps screen. @@ -482,7 +499,7 @@ open class BasePGPActivity : AppCompatActivity() { } var cacheEnabled = bundle.getBoolean(PasswordDialog.PASSWORD_CACHE_KEY) lifecycleScope.launch(dispatcherProvider.main()) { - decryptWithPassphrase(mapOf("" to passphrase), identifiers) { id -> // onSuccess + deliverDecrypt(mapOf("" to passphrase), identifiers) { id -> // onSuccess runCatching { // update temporary passphrase cache val isHardwareBacked = AESEncryption.isHardwareBacked() @@ -777,13 +794,13 @@ open class BasePGPActivity : AppCompatActivity() { lifecycleScope.launch(dispatcherProvider.main()) { if (!repository.isPasswordProtected(identifiers) && !isError) { // try passphraseless decryption first - decryptWithPassphrase(mapOf("" to null), identifiers) + deliverDecrypt(mapOf("" to null), identifiers) } else if (!isError && !passphrases.isEmpty()) { // try cached passphrases val decryptedCachedPassphrases = passphrases.mapValues { AESEncryption.decrypt(it.value) ?: charArrayOf() } - decryptWithPassphrase(decryptedCachedPassphrases, identifiers) + deliverDecrypt(decryptedCachedPassphrases, identifiers) decryptedCachedPassphrases.values.forEach { it.wipe() } } else { askPassphrase(isError, identifiers) @@ -791,6 +808,22 @@ open class BasePGPActivity : AppCompatActivity() { } } + /** + * Routes a decrypted-message request to the currently active sink: the reference-resolution sink + * while [activeReferenceDecrypt] is set, otherwise the subclass's [decryptWithPassphrase]. + */ + private suspend fun deliverDecrypt( + passphrases: Map, + identifiers: List, + onSuccess: suspend (String) -> Unit = {}, + ) { + if (activeReferenceDecrypt) { + decryptReferenceWithPassphrase(passphrases, identifiers, onSuccess) + } else { + decryptWithPassphrase(passphrases, identifiers, onSuccess) + } + } + /** Subclass-specific implementations */ open suspend fun decryptWithPassphrase( passphrases: Map, @@ -798,6 +831,88 @@ open class BasePGPActivity : AppCompatActivity() { onSuccess: suspend (String) -> Unit = {}, ) {} + /** + * Interactively unlocks the `gopass://` referenced file at [file], reusing the same + * key-check/passphrase/biometric/PIN flow as the primary entry, and returns its decrypted + * plaintext. Returns `null` if the user aborts or no decryption key is available. The caller owns + * (and must wipe) the returned array. + */ + protected suspend fun decryptReferencedFile(file: File): CharArray? { + val deferred = CompletableDeferred() + withContext(dispatcherProvider.main()) { + referenceDecryptPath = file.absolutePath + referenceDecryptSink = { plaintext, _ -> deferred.complete(plaintext) } + referenceDecryptFailure = { deferred.complete(null) } + activeReferenceDecrypt = true + requireKeysExist { + requireDecryptionKeysExist(PasswordRepository.getParentPath(file.absolutePath, repoPath)) { + ids -> + getPersistentAndDecrypt(ids) + } + } + } + return try { + deferred.await() + } finally { + activeReferenceDecrypt = false + referenceDecryptSink = null + referenceDecryptFailure = null + } + } + + /** + * Reference-resolution counterpart of [decryptWithPassphrase]: reads [referenceDecryptPath], + * decrypts it and hands the plaintext to [referenceDecryptSink]. Mirrors the shared error + * handling (wrong-passphrase retry, cache clearing) but performs no entry-specific work — no UI, + * history, or finishing. + */ + private suspend fun decryptReferenceWithPassphrase( + passphrases: Map, + identifiers: List, + onSuccess: suspend (String) -> Unit, + ) { + val message = + withContext(dispatcherProvider.io()) { File(referenceDecryptPath).readBytes().inputStream() } + val outputStream = ByteArrayOutputStream() + val results = repository.decrypt(passphrases, identifiers, message, outputStream) + val lastResult = results.last() + if (lastResult.second.isOk) { + val decryptedEntryBytes = lastResult.second.getOrThrow().toByteArray() + lastResult.second.getOrThrow().wipe() + val decryptedEntryChars = decryptedEntryBytes.toCharArray() + decryptedEntryBytes.wipe() + referenceDecryptSink?.invoke(decryptedEntryChars, lastResult.first) + onSuccess(lastResult.first) + } else { + passphrases.values.forEach { it?.wipe() } + if ( + results + .filter { result -> + if (result.second.getError() is IncorrectPassphraseException) { + /* Remove wrong passphrases from temporary and persistent caches */ + persistentPassphrases.edit { remove(result.first) } + cachedPassphrases[result.first]?.wipe() + cachedPassphrases.remove(result.first) + true + } else false + } + .any() + ) { + /* Retry */ + decrypt(identifiers, isError = true) + } else { + referenceDecryptFailure?.invoke() + } + results + .filter { it.second.getError() is Throwable } + .forEach { logcat { it.second.getError()?.asLog() ?: "unknown error" } } + } + if (!settings.getBoolean(PreferenceKeys.CACHE_PASSPHRASE, false)) { + cachedPassphrases.values.forEach { it.wipe() } + cachedPassphrases.clear() + } + } + protected fun retrievePasskey( entry: PasswordEntry, stripped: Boolean = false, // whether to wipe private key material diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt index 10510f4597..3849860444 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt @@ -17,8 +17,10 @@ import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.crypto.errors.IncorrectPassphraseException import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException +import app.passwordstore.data.crypto.PasswordReferenceResolver import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.password.FieldItem +import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.databinding.DecryptLayoutBinding import app.passwordstore.injection.prefs.CredentialUsernames import app.passwordstore.injection.prefs.PasswordHistory @@ -50,6 +52,7 @@ import kotlinx.coroutines.withContext class DecryptActivity : BasePGPActivity() { @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + @Inject lateinit var referenceResolver: PasswordReferenceResolver @CredentialUsernames @Inject lateinit var credentialUsernames: SharedPreferences @PasswordHistory @Inject lateinit var passwordHistory: SharedPreferences @@ -57,7 +60,9 @@ class DecryptActivity : BasePGPActivity() { private val binding by viewBinding(DecryptLayoutBinding::inflate) // temporarily AES-encrypted password entry - private var encryptedEntryChars: CharArray? = null // AES encrypted password entry + private var encryptedEntryChars: CharArray? = null // AES encrypted (reference-resolved) entry + // original, unresolved content (with any gopass:// reference intact) for editing + private var originalEncryptedEntryChars: CharArray? = null private fun CharArray.isBlank() = this.isEmpty() || this.all { it.isWhitespace() } @@ -85,6 +90,7 @@ class DecryptActivity : BasePGPActivity() { override fun onDestroy() { encryptedEntryChars?.wipe() + originalEncryptedEntryChars?.wipe() itemsAdapter?.clearItems() super.onDestroy() } @@ -103,8 +109,16 @@ class DecryptActivity : BasePGPActivity() { lastResult.second.getOrThrow().wipe() val decryptedEntryChars = decryptedEntryBytes.toCharArray() decryptedEntryBytes.wipe() - val entry = passwordEntryFactory.create(decryptedEntryChars) - encryptedEntryChars = AESEncryption.encrypt(decryptedEntryChars) + + // Resolve any gopass:// password reference to the target entry's password. + val effectiveChars = resolveReferences(decryptedEntryChars) + + val entry = passwordEntryFactory.create(effectiveChars.copyOf()) + encryptedEntryChars = AESEncryption.encrypt(effectiveChars) + effectiveChars.wipe() + // Keep the original (unresolved) content so that editing shows the gopass:// reference itself + // rather than the resolved password. + originalEncryptedEntryChars = AESEncryption.encrypt(decryptedEntryChars) decryptedEntryChars.wipe() entry.clearExtraChars() createPasswordUI(entry) @@ -183,6 +197,29 @@ class DecryptActivity : BasePGPActivity() { return true } + /** + * If [plaintext] is a gopass-style `gopass://` reference, returns the effective content with the + * referenced entry's password (and, as a fallback, its username/OTP) substituted in; otherwise + * returns a copy of [plaintext]. On failure the original content is shown and a notice is + * surfaced. + */ + private suspend fun resolveReferences(plaintext: CharArray): CharArray { + val resolved = + referenceResolver.resolve( + plaintext = plaintext.copyOf(), + repoRoot = PasswordRepository.getRepositoryDirectory(), + originPath = fullPath, + decrypt = { file -> decryptReferencedFile(file) }, + ) + return when (resolved) { + is PasswordReferenceResolver.Result.Resolved -> resolved.plaintext + is PasswordReferenceResolver.Result.Unresolved -> { + snackbar(message = getString(R.string.password_reference_unresolved)) + plaintext.copyOf() + } + } + } + private fun copyPassword() { encryptedEntryChars?.let { encrypted -> AESEncryption.decrypt(encrypted)?.let { decrypted -> @@ -198,7 +235,7 @@ class DecryptActivity : BasePGPActivity() { } private fun editPassword() { - encryptedEntryChars?.let { encrypted -> + (originalEncryptedEntryChars ?: encryptedEntryChars)?.let { encrypted -> val intent = Intent(this, PasswordCreationActivity::class.java) intent.action = Intent.ACTION_VIEW intent.putExtra(EXTRA_FILE_PATH, Paths.get(fullPath).parent.pathString) @@ -212,7 +249,7 @@ class DecryptActivity : BasePGPActivity() { } private fun editPasskey() { - encryptedEntryChars?.let { encrypted -> + (originalEncryptedEntryChars ?: encryptedEntryChars)?.let { encrypted -> val intent = Intent(this, PasskeyCreationActivity::class.java) intent.action = Intent.ACTION_VIEW intent.putExtra(EXTRA_FILE_PATH, Paths.get(fullPath).parent.pathString) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82a460227a..9528bdf8e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,6 +355,7 @@ Decryption failed. No suitable decryption key found Unknown decryption error + Could not resolve the gopass:// password reference Unknown error diff --git a/app/src/test/java/app/passwordstore/data/crypto/PasswordReferenceResolverTest.kt b/app/src/test/java/app/passwordstore/data/crypto/PasswordReferenceResolverTest.kt new file mode 100644 index 0000000000..7db121f1e2 --- /dev/null +++ b/app/src/test/java/app/passwordstore/data/crypto/PasswordReferenceResolverTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.data.crypto + +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.util.time.UserClock +import app.passwordstore.util.totp.UriTotpFinder +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class PasswordReferenceResolverTest { + + private val totpFinder = UriTotpFinder() + + private val factory = + object : PasswordEntry.Factory { + override fun create(chars: CharArray) = PasswordEntry(UserClock(), totpFinder, chars) + } + + private val resolver = PasswordReferenceResolver(factory) + private val repoRoot: File = Files.createTempDirectory("store").toFile() + + private fun path(name: String) = File(repoRoot, "$name.gpg").absolutePath + + private fun parse(chars: CharArray) = factory.create(chars.copyOf()) + + @Test + fun nonReferenceIsReturnedUnchanged() = runBlocking { + val plain = "hunter2\nusername: alice".toCharArray() + val result = + resolver.resolve(plain, repoRoot, path("a")) { error("should not decrypt a non-reference") } + val resolved = assertIs(result) + assertEquals("hunter2\nusername: alice", String(resolved.plaintext)) + } + + @Test + fun substitutesPasswordAndInheritsUsername() = runBlocking { + val origin = "gopass://services/db/pg".toCharArray() + val result = + resolver.resolve(origin, repoRoot, path("apps/billing")) { file -> + assertEquals(File(repoRoot, "services/db/pg.gpg").canonicalPath, file.canonicalPath) + "realpass\nusername: dbuser".toCharArray() + } + val resolved = assertIs(result) + val entry = parse(resolved.plaintext) + assertEquals("realpass", entry.password?.let { String(it) }) + assertEquals("dbuser", entry.username?.let { String(it) }) + } + + @Test + fun keepsOriginsOwnUsername() = runBlocking { + val origin = "gopass://b\nusername: mine".toCharArray() + val result = + resolver.resolve(origin, repoRoot, path("a")) { "realpass\nusername: theirs".toCharArray() } + val resolved = assertIs(result) + val entry = parse(resolved.plaintext) + assertEquals("realpass", entry.password?.let { String(it) }) + assertEquals("mine", entry.username?.let { String(it) }) + } + + @Test + fun resolvesNestedChain() = runBlocking { + val origin = "gopass://b".toCharArray() + val result = + resolver.resolve(origin, repoRoot, path("a")) { file -> + when (file.name) { + "b.gpg" -> "gopass://c".toCharArray() + "c.gpg" -> "finalpass\nusername: z".toCharArray() + else -> error("unexpected file ${file.name}") + } + } + val resolved = assertIs(result) + val entry = parse(resolved.plaintext) + assertEquals("finalpass", entry.password?.let { String(it) }) + assertEquals("z", entry.username?.let { String(it) }) + } + + @Test + fun detectsCycle() = runBlocking { + // a -> b -> a + val origin = "gopass://b".toCharArray() + val result = + resolver.resolve(origin, repoRoot, path("a")) { file -> + when (file.name) { + "b.gpg" -> "gopass://a".toCharArray() + else -> error("unexpected file ${file.name}") + } + } + val unresolved = assertIs(result) + assertEquals(PasswordReferenceResolver.Reason.CYCLE, unresolved.reason) + } + + @Test + fun rejectsPathTraversal() = runBlocking { + val origin = "gopass://../../../etc/passwd".toCharArray() + val result = + resolver.resolve(origin, repoRoot, path("a")) { error("should not decrypt an invalid path") } + val unresolved = assertIs(result) + assertEquals(PasswordReferenceResolver.Reason.INVALID_PATH, unresolved.reason) + } + + @Test + fun stopsAtMaxDepth() = runBlocking { + // An unbounded chain ref0 -> ref1 -> ref2 -> ... must be cut off rather than recurse forever. + val origin = "gopass://ref1".toCharArray() + val result = + resolver.resolve(origin, repoRoot, path("ref0")) { file -> + val n = file.nameWithoutExtension.removePrefix("ref").toInt() + "gopass://ref${n + 1}".toCharArray() + } + val unresolved = assertIs(result) + assertEquals(PasswordReferenceResolver.Reason.TOO_DEEP, unresolved.reason) + } + + @Test + fun abortedUnlockLeavesReferenceUnresolved() = runBlocking { + val origin = "gopass://b".toCharArray() + val result = resolver.resolve(origin, repoRoot, path("a")) { null } + val unresolved = assertIs(result) + assertEquals(PasswordReferenceResolver.Reason.ABORTED, unresolved.reason) + } + + @Test + fun mergeInheritsTargetTotpWhenOriginHasNone() { + val origin = "gopass://b".toCharArray() + val target = "realpass\notpauth://totp/test?secret=JBSWY3DPEHPK3PXP".toCharArray() + val merged = parse(resolver.merge(origin, target)) + assertEquals("realpass", merged.password?.let { String(it) }) + assertTrue(merged.hasTotp()) + } + + @Test + fun mergeKeepsOriginTotpOverTarget() { + val origin = "gopass://b\notpauth://totp/mine?secret=JBSWY3DPEHPK3PXP".toCharArray() + val target = "realpass\notpauth://totp/theirs?secret=GEZDGNBVGY3TQOJQ".toCharArray() + val merged = parse(resolver.merge(origin, target)) + // origin defines its own TOTP, so no extra TOTP line is appended from the target + assertFalse(String(merged.extraContentChars ?: charArrayOf()).contains("theirs")) + assertNull(merged.username) + } +} diff --git a/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt b/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt index 79e342dc48..39aa859a55 100644 --- a/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt +++ b/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt @@ -140,6 +140,25 @@ constructor( return totpSecret != null } + /** + * Whether this entry's password is a gopass-style cross-secret reference, i.e. its password is a + * `gopass://` URI pointing at another entry whose password should be used instead. See + * [referencePath] to obtain the referenced entry's store-relative path. + */ + public fun isReference(): Boolean = + password?.startsWith(REFERENCE_SCHEME, ignoreCase = true) == true + + /** + * The store-relative path of the entry referenced by this entry's `gopass://` password, or `null` + * if this entry is not a [reference][isReference]. The returned path has no leading slash and no + * `.gpg` suffix, matching gopass semantics (e.g. `gopass://services/db/pg` -> `services/db/pg`). + */ + public fun referencePath(): String? { + val pass = password ?: return null + if (!pass.startsWith(REFERENCE_SCHEME, ignoreCase = true)) return null + return String(pass.copyOfRange(REFERENCE_SCHEME.length, pass.size)).trim().trimStart('/') + } + @Suppress("ReturnCount") private fun findAndStripPassword( passContent: List @@ -326,6 +345,9 @@ constructor( public val EXTRA_CONTENT: String = "EXTRA_CONTENT" + /** URI scheme marking a gopass-style cross-secret password reference. */ + public const val REFERENCE_SCHEME: String = "gopass://" + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) public val USERNAME_FIELDS: Array = arrayOf( diff --git a/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt b/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt index 0b98ec1271..81780f3e45 100644 --- a/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt +++ b/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt @@ -118,6 +118,27 @@ class PasswordEntryTest { assertEquals(":", entry.extraContent["EXTRA_CONTENT"]?.concatToString()) } + @Test + fun detectsReference() { + val ref = makeEntry("gopass://services/db/pg") + assertTrue(ref.isReference()) + assertEquals("services/db/pg", ref.referencePath()) + + // scheme is case-insensitive and leading slashes / whitespace are trimmed + val messyRef = makeEntry("GOPASS:///services/db/pg \nnote: shared\n") + assertTrue(messyRef.isReference()) + assertEquals("services/db/pg", messyRef.referencePath()) + + // a reference only counts when it is the password (first) line + val notRef = makeEntry("hunter2\nurl: gopass://services/db/pg") + assertFalse(notRef.isReference()) + assertNull(notRef.referencePath()) + + val plain = makeEntry("hunter2\nusername: alice") + assertFalse(plain.isReference()) + assertNull(plain.referencePath()) + } + @Test fun getUsername() { for (field in PasswordEntry.USERNAME_FIELDS) {