mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
fix(cashu): case-insensitive P2PK lock match + all-or-nothing redeem
Two bugs found auditing the P2PK redeem path: - Hex case: a lock's `data` pubkey is sender-formatted and NUT-11 doesn't mandate a case, but our key index is keyed by lowercase x-only (Hex.encode is lowercase). An uppercase/mixed-case lock we actually hold the key for was falsely rejected as unredeemable. Normalize the lock to lowercase before the lookup, and compare identity-key locks case-insensitively. - Multi-mint partial redeem: callers redeem one mint-group at a time, each swapping + publishing. An unsignable P2PK lock in a later group threw only after earlier groups were already spent + published, leaving a half-redeemed state the user was told had failed. Add `firstUnsignableP2pkLock` / `requireP2pkRedeemable` and pre-flight every group before redeeming any, mirroring the existing unknown-mint pre-check (wallet ViewModel + amy CLI). Also document that P2PK.signWitness's `["P2PK"` prefix guard is load-bearing for safety (prevents cross-protocol signature reuse when signing with the identity key), not just for parsing. Adds tests for case-insensitive matching and the pre-flight helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
This commit is contained in:
+4
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.MintQuoteStarted
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.describeRedeemError
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.requireP2pkRedeemable
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -919,6 +920,9 @@ class CashuWalletViewModel : ViewModel() {
|
||||
} else {
|
||||
null to null
|
||||
}
|
||||
// All-or-nothing: reject an unsignable P2PK lock before redeeming
|
||||
// any group, so a multi-mint token never ends up half-redeemed.
|
||||
requireP2pkRedeemable(parsedTokens.flatMap { it.proofs }, walletKey, identityKey)
|
||||
val total =
|
||||
parsedTokens.sumOf {
|
||||
ops
|
||||
|
||||
+4
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.commands.route
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.requireP2pkRedeemable
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
@@ -169,6 +170,9 @@ object CashuReceiveCommands {
|
||||
val walletKey =
|
||||
if (locked) ctx.cashuSnapshot().walletEvent?.let { runCatching { it.privkey(ctx.signer) }.getOrNull() } else null
|
||||
val identityKey = if (locked) (ctx.signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey() else null
|
||||
// All-or-nothing: reject an unsignable P2PK lock before redeeming
|
||||
// any group, so a multi-mint token never ends up half-redeemed.
|
||||
if (locked) requireP2pkRedeemable(parsed.flatMap { it.proofs }, walletKey, identityKey)
|
||||
var total = 0L
|
||||
var lastTokenEventId: String? = null
|
||||
var lastHistoryEventId: String? = null
|
||||
|
||||
+21
-1
@@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.nip60Cashu.mintApi.SecretFactory
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.splitAmountIntoDenominations
|
||||
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
|
||||
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PKUnredeemableException
|
||||
import com.vitorpamplona.quartz.nip60Cashu.p2pk.firstUnsignableP2pkLock
|
||||
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
@@ -1306,6 +1307,8 @@ private fun p2pkKeyIndex(vararg privKeysHex: String?): Map<String, String> =
|
||||
buildMap {
|
||||
privKeysHex.forEach { hex ->
|
||||
if (!hex.isNullOrBlank()) {
|
||||
// toHexKey() emits lowercase, matching the lowercased x-only the
|
||||
// resolver is queried with (see P2PKRedeem.xOnly).
|
||||
val xOnly =
|
||||
Secp256k1
|
||||
.pubKeyCompress(Secp256k1.pubkeyCreate(hex.hexToByteArray()))
|
||||
@@ -1316,6 +1319,23 @@ private fun p2pkKeyIndex(vararg privKeysHex: String?): Map<String, String> =
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight a token's proofs against the keys we hold: throws
|
||||
* [P2PKUnredeemableException] (naming the offending lock) if any P2PK-locked
|
||||
* proof can't be signed. Callers redeem a multi-group token one group at a time,
|
||||
* each swapping + publishing, so checking every group up front avoids redeeming
|
||||
* some and then failing on an unsignable one — mirroring the unknown-mint
|
||||
* pre-check. Plain (unlocked) proofs are ignored.
|
||||
*/
|
||||
fun requireP2pkRedeemable(
|
||||
proofs: List<CashuProof>,
|
||||
walletP2pkPrivkeyHex: String?,
|
||||
identityPrivkeyHex: String?,
|
||||
) {
|
||||
val signingKeys = p2pkKeyIndex(walletP2pkPrivkeyHex, identityPrivkeyHex)
|
||||
firstUnsignableP2pkLock(proofs) { signingKeys[it] }?.let { throw P2PKUnredeemableException(it) }
|
||||
}
|
||||
|
||||
/** Catches mint HTTP / protocol errors and surfaces their detail message. */
|
||||
fun describeMintError(e: Throwable): String =
|
||||
when (e) {
|
||||
@@ -1338,7 +1358,7 @@ fun describeRedeemError(
|
||||
): String =
|
||||
if (e is P2PKUnredeemableException) {
|
||||
val lock = e.lockPubKeyHex.lastHex64()
|
||||
if (identityPubKeyHex != null && lock == identityPubKeyHex.lastHex64()) {
|
||||
if (identityPubKeyHex != null && lock.equals(identityPubKeyHex.lastHex64(), ignoreCase = true)) {
|
||||
"This ecash is locked to your Nostr identity key, which the current login can't sign for " +
|
||||
"(only a local key / nsec login can). Import your nsec into a Cashu wallet to claim it."
|
||||
} else {
|
||||
|
||||
@@ -125,6 +125,14 @@ object P2PK {
|
||||
* BIP-340 Schnorr signature over `sha256(secret_bytes)` used as the unlock
|
||||
* witness. Returns the witness JSON string ready to drop into a Cashu
|
||||
* proof's `witness` field.
|
||||
*
|
||||
* SECURITY: [secret] is attacker-controlled (it comes from a pasted token),
|
||||
* and when [privKeyHex] is the account's Nostr identity key this signs
|
||||
* `sha256(secret)` with that key. Cross-protocol reuse against Nostr event
|
||||
* signing is prevented only because a valid P2PK secret must start with
|
||||
* `["P2PK"` while a Nostr event serialization starts with `[0,` — so callers
|
||||
* MUST only reach here for secrets that already passed [parseSecret]; that
|
||||
* prefix check is load-bearing for safety, not just parsing.
|
||||
*/
|
||||
fun signWitness(
|
||||
secret: String,
|
||||
|
||||
+25
-4
@@ -55,13 +55,34 @@ fun signP2pkWitnesses(
|
||||
): List<CashuProof> =
|
||||
proofs.map { proof ->
|
||||
val parsed = P2PK.parseSecret(proof.secret) ?: return@map proof
|
||||
val xOnly = parsed.pubKeyHex.xOnly()
|
||||
val privKeyHex = signingKeyFor(xOnly) ?: throw P2PKUnredeemableException(parsed.pubKeyHex)
|
||||
val privKeyHex = signingKeyFor(parsed.pubKeyHex.xOnly()) ?: throw P2PKUnredeemableException(parsed.pubKeyHex)
|
||||
proof.copy(witness = P2PK.signWitness(proof.secret, privKeyHex))
|
||||
}
|
||||
|
||||
/**
|
||||
* The first P2PK lock across [proofs] that [signingKeyFor] can't resolve, or
|
||||
* null if every locked proof is signable (plain proofs are ignored). Lets a
|
||||
* caller pre-flight a multi-group token so it never swaps some groups and then
|
||||
* discovers a later group is unredeemable — leaving a half-redeemed state.
|
||||
*/
|
||||
fun firstUnsignableP2pkLock(
|
||||
proofs: List<CashuProof>,
|
||||
signingKeyFor: (lockPubKeyXOnly: String) -> String?,
|
||||
): String? {
|
||||
proofs.forEach { proof ->
|
||||
val parsed = P2PK.parseSecret(proof.secret) ?: return@forEach
|
||||
if (signingKeyFor(parsed.pubKeyHex.xOnly()) == null) return parsed.pubKeyHex
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** True when any proof in the set carries a NUT-11 P2PK-locked secret. */
|
||||
fun List<CashuProof>.anyP2pkLocked(): Boolean = any { P2PK.parseSecret(it.secret) != null }
|
||||
|
||||
/** Drop a 33-byte compressed pubkey's parity prefix, yielding the 32-byte x-only hex. */
|
||||
private fun String.xOnly(): String = if (length == 66) substring(2) else this
|
||||
/**
|
||||
* Drop a 33-byte compressed pubkey's parity prefix, yielding the 32-byte x-only
|
||||
* hex, and lowercase it. The `data` field is formatted by the sender and NUT-11
|
||||
* doesn't mandate a case, so normalize before matching against our (lowercase)
|
||||
* key index — otherwise an uppercase lock we *can* sign for is falsely rejected.
|
||||
*/
|
||||
private fun String.xOnly(): String = (if (length == 66) substring(2) else this).lowercase()
|
||||
|
||||
+29
@@ -133,4 +133,33 @@ class P2PKRedeemTest {
|
||||
assertFalse(listOf(plainProof()).anyP2pkLocked())
|
||||
assertTrue(listOf(plainProof(), lockedProof(xOnlyPub)).anyP2pkLocked())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uppercaseLockMatchesLowercaseKeyIndex() {
|
||||
// NUT-11 doesn't mandate a hex case for `data`. Our key index is keyed
|
||||
// by lowercase x-only (Hex.encode is lowercase), so an uppercase lock we
|
||||
// hold the key for must still resolve — not be reported unredeemable.
|
||||
val upperLock = "02${xOnlyPub.uppercase()}"
|
||||
val index = mapOf(xOnlyPub to priv)
|
||||
val out = signP2pkWitnesses(listOf(lockedProof(upperLock))) { index[it] }
|
||||
assertTrue(witnessVerifies(out[0], xOnlyPub), "an uppercase lock we hold the key for must sign")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstUnsignableReturnsNullWhenAllSignable() {
|
||||
assertNull(firstUnsignableP2pkLock(listOf(plainProof(), lockedProof(xOnlyPub))) { priv })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstUnsignableNamesTheUnredeemableLock() {
|
||||
val other = "02${"b".repeat(64)}"
|
||||
assertEquals(other, firstUnsignableP2pkLock(listOf(plainProof(), lockedProof(other))) { null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstUnsignableIsCaseInsensitive() {
|
||||
val upperLock = "02${xOnlyPub.uppercase()}"
|
||||
val index = mapOf(xOnlyPub to priv)
|
||||
assertNull(firstUnsignableP2pkLock(listOf(lockedProof(upperLock))) { index[it] })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user