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:
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;