taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit b565f5670ab7e1330fcbb2c0286b63362f05d373
parent 667f5fec1c36dc23d494e3dc86f335529a6ce272
Author: Florian Dold <dold@taler.net>
Date:   Thu, 10 Sep 2026 01:31:48 +0200

wallet-core: share refresh derivation and pin melt indices

Expose deterministic refresh derivation and use a stored CS blinding
seed when reconstructing an existing refresh.

Share melt confirmation authentication between normal refresh and
recovery. Persist the first authenticated no-reveal index before any
batch disclosure and reject conflicting indices on retries.

Diffstat:
Mpackages/taler-wallet-core/src/refresh.ts | 138+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
1 file changed, 90 insertions(+), 48 deletions(-)

diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -757,6 +757,82 @@ type DerivedRefresh = transferPubs: EddsaPublicKeyString[][]; }); +/** Authenticate the exchange's cut-and-choose selection before disclosing any batch. */ +export async function requireValidMeltConfirmation( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + refreshCommitment: string, + meltResponse: import("@gnu-taler/taler-util").ExchangeMeltResponse, +): Promise<void> { + const norevealIndex = meltResponse.noreveal_index; + requireValidNorevealIndex(norevealIndex, 3); + const signingKeyKnown = await wex.runWalletDbTx(async (tx) => { + const details = await tx.getExchangeDetails(exchangeBaseUrl); + if (details?.rowId == null) { + return false; + } + const signKeys = await tx.getExchangeSignKeysByDetailsRowId(details.rowId); + const now = AbsoluteTime.now(); + const tolerance = Duration.fromSpec({ hours: 1 }); + const latestStart = AbsoluteTime.addDuration(now, tolerance); + const earliestExpiry = AbsoluteTime.subtractDuraction(now, tolerance); + return signKeys.some( + (x) => + x.signkeyPub === meltResponse.exchange_pub && + AbsoluteTime.cmp( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(x.stampStart), + ), + latestStart, + ) <= 0 && + AbsoluteTime.cmp( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(x.stampExpire), + ), + earliestExpiry, + ) > 0, + ); + }); + const { valid: signatureValid } = await wex.cryptoApi.isValidMeltConfirmation( + { + refreshCommitment, + norevealIndex, + exchangePub: meltResponse.exchange_pub, + exchangeSig: meltResponse.exchange_sig, + }, + ); + if (!signingKeyKnown || !signatureValid) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + "exchange returned an invalid melt confirmation signature", + ); + } + // Pin before either a normal refresh or recovery reveals any batch. Sharing + // this pin also prevents concurrent normal and recovery requests from + // accepting different authenticated indices for the same commitment. + await wex.runWalletDbTx(async (tx) => { + const rec = await tx.getRefreshMeltConfirmation({ + exchangeBaseUrl, + refreshCommitment, + }); + const previous = rec?.norevealIndex; + if (previous !== undefined && previous !== norevealIndex) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + {}, + "exchange changed a previously confirmed no-reveal index", + ); + } + if (previous === undefined) + await tx.upsertRefreshMeltConfirmation({ + exchangeBaseUrl, + refreshCommitment, + norevealIndex, + }); + }); +} + /** * Re-derive everything about a refresh session from the stored seed, using the * refresh protocol the session melted with. @@ -764,7 +840,7 @@ type DerivedRefresh = * The derivation is deterministic, so the melt and the reveal step both call * this instead of carrying the derived secrets across the two requests. */ -async function deriveRefreshSession( +export async function deriveRefreshSession( wex: WalletExecutionContext, refreshSession: WalletRefreshSession, oldCoin: WalletCoin, @@ -787,12 +863,14 @@ async function deriveRefreshSession( let blindingSeed: string | undefined; let exchangeWithdrawValues: (ExchangeWithdrawValue | undefined)[] | undefined; if (nks.length > 0) { - blindingSeed = encodeCrock( - deriveCsRefreshBlindingSeed( - decodeCrock(refreshSession.sessionPublicSeed), - decodeCrock(oldCoin.coinPriv), - ), - ); + blindingSeed = + refreshSession.blindingSeed ?? + encodeCrock( + deriveCsRefreshBlindingSeed( + decodeCrock(refreshSession.sessionPublicSeed), + decodeCrock(oldCoin.coinPriv), + ), + ); const prep = succeedOrThrow( await walletExchangeClient( oldCoin.exchangeBaseUrl, @@ -1339,48 +1417,12 @@ async function refreshMelt( const norevealIndex = meltResponse.noreveal_index; requireValidNorevealIndex(norevealIndex, derived.planchets.length); - const signingKeyKnown = await wex.runWalletDbTx(async (tx) => { - const details = await tx.getExchangeDetails(oldCoin.exchangeBaseUrl); - if (details?.rowId == null) { - return false; - } - const signKeys = await tx.getExchangeSignKeysByDetailsRowId(details.rowId); - const now = AbsoluteTime.now(); - const tolerance = Duration.fromSpec({ hours: 1 }); - const latestStart = AbsoluteTime.addDuration(now, tolerance); - const earliestExpiry = AbsoluteTime.subtractDuraction(now, tolerance); - return signKeys.some( - (x) => - x.signkeyPub === meltResponse.exchange_pub && - AbsoluteTime.cmp( - AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(x.stampStart), - ), - latestStart, - ) <= 0 && - AbsoluteTime.cmp( - AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(x.stampExpire), - ), - earliestExpiry, - ) > 0, - ); - }); - const { valid: signatureValid } = await wex.cryptoApi.isValidMeltConfirmation( - { - refreshCommitment: derived.hash, - norevealIndex, - exchangePub: meltResponse.exchange_pub, - exchangeSig: meltResponse.exchange_sig, - }, + await requireValidMeltConfirmation( + wex, + oldCoin.exchangeBaseUrl, + derived.hash, + meltResponse, ); - if (!signingKeyKnown || !signatureValid) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, - {}, - "exchange returned an invalid melt confirmation signature", - ); - } refreshSession.norevealIndex = norevealIndex;