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:
Claude
2026-07-27 01:26:36 +00:00
parent ee5efba88d
commit 3c440a6d27
6 changed files with 91 additions and 5 deletions
@@ -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
@@ -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
@@ -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,
@@ -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()
@@ -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] })
}
}