taler-typescript-core

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

commit 6effcf5aeef4d19f71e6e1cf5c736f0fa49c36c8
parent c4a416fc79a9b1d1824e8bd2ff93393d4f5f8bd4
Author: Özgür Kesim <oec@codeblau.de>
Date:   Tue, 27 Oct 2026 14:17:28 +0100

wallet-core: v45 refresh commitment covers transfer pubs (#11862)

Also fixes coin recovery, which sent transfer_pubs and batch_seeds
for v32 only.

Diffstat:
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 40++++++++++++++++++++++++++++------------
Mpackages/taler-wallet-core/src/crypto/cryptoTypes.ts | 13+++++++++++--
Mpackages/taler-wallet-core/src/db/records.ts | 4++--
Mpackages/taler-wallet-core/src/dbless.ts | 41++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/refresh.ts | 41+++++++++++++++++++++++++++++------------
Mpackages/taler-wallet-core/src/testing-recover-coins.test.ts | 8++++++--
Mpackages/taler-wallet-core/src/testing-recover-coins.ts | 6+++---
8 files changed, 182 insertions(+), 44 deletions(-)

diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts @@ -28,11 +28,13 @@ import { DenomKeyType, eddsaSign, encodeCrock, + getRandomBytes, EddsaPublicKeyString, EddsaSignatureString, ExchangeSignKeyJson, GlobalFees, hash, + hashDenomPub, hashTruncate32, MerchantContractOutputType, MerchantContractTermsV1, @@ -852,3 +854,74 @@ test("order unclaim signs the contract hash with the wallet nonce", async () => encodeCrock(eddsaSign(expectedPayload, nonce.eddsaPriv)), ); }); + +test("refresh commitment covers the transfer public keys only since v45", async () => { + const denomPub = { + cipher: DenomKeyType.Rsa as const, + rsa_public_key: + "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002", + age_mask: 0, + }; + const meltCoin = createEddsaKeyPair(); + const req = { + kappa: 3, + sessionPublicSeed: encodeCrock(getRandomBytes(64)), + meltCoinPub: encodeCrock(meltCoin.eddsaPub), + meltCoinPriv: encodeCrock(meltCoin.eddsaPriv), + meltCoinDenomPubHash: encodeCrock(new Uint8Array(64).fill(3)), + meltCoinMaxAge: 0, + feeRefresh: Amounts.parseOrThrow("KUDOS:0.01"), + newCoinDenoms: [ + { + count: 2, + denomPub, + denomPubHash: encodeCrock(hashDenomPub(denomPub)), + feeWithdraw: "KUDOS:0.01" as AmountString, + value: "KUDOS:1" as AmountString, + }, + ], + }; + const legacy = await nativeCryptoR.deriveRefreshSessionV3(nativeCryptoR, { + ...req, + includeTransferPubsInCommitment: false, + }); + const current = await nativeCryptoR.deriveRefreshSessionV3(nativeCryptoR, { + ...req, + includeTransferPubsInCommitment: true, + }); + + // Only the commitment differs, not what is derived or sent. + assert.deepStrictEqual(current.transferPubs, legacy.transferPubs); + assert.deepStrictEqual(current.planchets, legacy.planchets); + assert.equal(current.transferPubs.length, 3); + assert.equal(current.transferPubs[0].length, 2); + + // Recompute the commitment from its parts, as TALER_refresh_get_commitment + // in the exchange does. + const expected = (withTransferPubs: boolean): string => { + const hc = createHashContext(); + hc.update(decodeCrock(req.sessionPublicSeed)); + if (withTransferPubs) { + for (const batch of current.transferPubs) { + for (const pub of batch) { + hc.update(decodeCrock(pub)); + } + } + } + hc.update(new Uint8Array(32)); // no Clause-Schnorr: zero blinding seed + hc.update(decodeCrock(req.meltCoinPub)); + hc.update(bufferFromAmount(current.meltValueWithFee)); + for (const batch of current.planchets) { + const bhc = createHashContext(); + for (const p of batch) { + bhc.update(decodeCrock(p.coinEvHash)); + } + hc.update(bhc.finish()); + } + return encodeCrock(hc.finish()); + }; + assert.equal(legacy.hash, expected(false)); + assert.equal(current.hash, expected(true)); + assert.notEqual(current.hash, legacy.hash); + assert.notEqual(current.confirmSig, legacy.confirmSig); +}); diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -2592,8 +2592,10 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { * in the melt step is what makes the fresh coins recoverable from the * exchange's link data, which the v27 signature-based derivation gave up. * - * The refresh commitment (session hash) is computed exactly as in v27: the - * transfer public keys do not enter it. + * The refresh commitment (session hash) is computed as in v27, except that + * with {@link DeriveRefreshSessionRequestV3.includeTransferPubsInCommitment} + * (exchange protocol v45 and later) the transfer public keys enter it, + * right after the public seed. */ async deriveRefreshSessionV3( tci: TalerCryptoInterfaceR, @@ -2642,15 +2644,8 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { const transferPubsForGammas: EddsaPublicKeyString[][] = []; const batchSeeds: HashCodeString[] = []; - const sessionHc = createHashContext(); - - sessionHc.update(decodeCrock(sessionPublicSeed)); - const blindingSeed = req.blindingSeed - ? decodeCrock(req.blindingSeed) - : new Uint8Array(32); - sessionHc.update(blindingSeed); - sessionHc.update(decodeCrock(meltCoinPub)); - sessionHc.update(bufferFromAmount(valueWithFee)); + // One hash over the blinded planchets of each candidate batch, in order. + const planchetsHashes: Uint8Array[] = []; for (let i = 0; i < kappa; i++) { const planchets: RefreshPlanchetInfo[] = []; @@ -2741,12 +2736,33 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { } } - sessionHc.update(planchetsHc.finish()); + planchetsHashes.push(planchetsHc.finish()); planchetsForGammas.push(planchets); transferPubsForGammas.push(transferPubs); } + // See TALER_refresh_get_commitment in the C impl. The transfer public + // keys precede the blinding seed, so the hash can only be computed once + // all batches are derived. + const sessionHc = createHashContext(); + sessionHc.update(decodeCrock(sessionPublicSeed)); + if (req.includeTransferPubsInCommitment) { + for (const transferPubs of transferPubsForGammas) { + for (const transferPub of transferPubs) { + sessionHc.update(decodeCrock(transferPub)); + } + } + } + const blindingSeed = req.blindingSeed + ? decodeCrock(req.blindingSeed) + : new Uint8Array(32); + sessionHc.update(blindingSeed); + sessionHc.update(decodeCrock(meltCoinPub)); + sessionHc.update(bufferFromAmount(valueWithFee)); + for (const planchetsHash of planchetsHashes) { + sessionHc.update(planchetsHash); + } const sessionHash = sessionHc.finish(); let hAgeCommitment: Uint8Array; if (req.meltCoinAgeCommitmentProof) { diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -94,10 +94,19 @@ export interface DeriveRefreshSessionRequestV2 { * Request to derive a refresh session from the refresh session * public seed, for the refresh protocol of exchange protocol v32. * - * Same inputs as {@link DeriveRefreshSessionRequestV2}; only the derivation - * of the fresh coins from the seed differs. + * Same inputs as {@link DeriveRefreshSessionRequestV2}, plus + * {@link includeTransferPubsInCommitment}; only the derivation of the fresh + * coins from the seed differs. */ export interface DeriveRefreshSessionRequestV3 { + /** + * Whether the refresh commitment covers the transfer public keys. + * + * Exchanges since protocol v45 expect this; earlier ones only accept the + * commitment without the transfer public keys, although they receive them + * in the melt request. + */ + includeTransferPubsInCommitment: boolean; sessionPublicSeed: string; kappa: number; meltCoinPub: string; diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -485,8 +485,8 @@ export interface WalletRefreshSession { * * The melt and the reveal step must agree on it, so it cannot be re-derived * from the exchange's advertised version later: the exchange may have been - * upgraded in between. Absent means 27, which is what sessions written - * before this field existed use. + * upgraded in between. One of 27, 32 or 45; absent means 27, which is what + * sessions written before this field existed use. */ refreshProtocolVersion?: number; diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts @@ -44,6 +44,7 @@ import { Result, TalerBankIntegrationHttpClient, TalerCorebankApiClient, + LibtoolVersion, TalerExchangeHttpClient, TalerProtocolTimestamp, TalerUriAction, @@ -392,6 +393,20 @@ export function getDepositTimestamps(timestamp?: TalerProtocolTimestamp): { }; } +/** + * The refresh protocol a current wallet speaks with the exchange: 45 if the + * exchange expects the transfer public keys in the refresh commitment (since + * protocol v45), 32 otherwise. + */ +async function currentRefreshProtocol( + exchangeClient: TalerExchangeHttpClient, +): Promise<32 | 45> { + const config = succeedOrThrow(await exchangeClient.getConfig()); + return LibtoolVersion.parseVersionOrThrow(config.version).current >= 45 + ? 45 + : 32; +} + export async function refreshCoin(req: { http: HttpRequestLibrary; cryptoApi: TalerCryptoInterface; @@ -399,13 +414,18 @@ export async function refreshCoin(req: { newDenoms: DenominationInfo[]; /** * Refresh protocol to speak, named after the exchange protocol version that - * introduced it. Defaults to the current one; pass 27 to exercise the - * protocol that wallets with an in-flight refresh group still use. + * introduced it. Defaults to the one a current wallet speaks with the + * exchange; pass 27 or 32 to exercise the protocols that wallets with an + * in-flight refresh group still use. */ - protocolVersion?: 27 | 32; + protocolVersion?: 27 | 32 | 45; }): Promise<void> { const { cryptoApi, oldCoin, http } = req; - const protocolVersion = req.protocolVersion ?? 32; + const exchangeClient = new TalerExchangeHttpClient(oldCoin.exchangeBaseUrl, { + httpClient: http, + }); + const protocolVersion = + req.protocolVersion ?? (await currentRefreshProtocol(exchangeClient)); const refreshSessionSeed = encodeCrock(getRandomBytes(64)); const sessionReq = { feeRefresh: Amounts.parseOrThrow(oldCoin.feeRefresh), @@ -424,9 +444,12 @@ export async function refreshCoin(req: { meltCoinMaxAge: oldCoin.maxAge, }; const session = - protocolVersion === 32 - ? await cryptoApi.deriveRefreshSessionV3(sessionReq) - : await cryptoApi.deriveRefreshSessionV2(sessionReq); + protocolVersion === 27 + ? await cryptoApi.deriveRefreshSessionV2(sessionReq) + : await cryptoApi.deriveRefreshSessionV3({ + ...sessionReq, + includeTransferPubsInCommitment: protocolVersion === 45, + }); const meltReqBody: ExchangeMeltRequestV2 = { old_coin_pub: oldCoin.coinPub, @@ -443,10 +466,6 @@ export async function refreshCoin(req: { logger.info(`requesting melt: ${j2s(meltReqBody)}`); - const exchangeClient = new TalerExchangeHttpClient(oldCoin.exchangeBaseUrl, { - httpClient: http, - }); - const meltResponse = succeedOrThrow( await exchangeClient.postMelt({ body: meltReqBody }), ); diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -713,10 +713,18 @@ function getRefreshRequestTimeout(rg: WalletRefreshGroup): Duration { * those signatures. In v32 they are derived via transfer keys again: the * reveal step discloses batch seeds instead, and the melt step publishes the * transfer public keys, which is what lets the owner of the old coin recover - * the fresh coins from the exchange's link data. + * the fresh coins from the exchange's link data. In v45 the refresh + * commitment also covers those transfer public keys (#11862); otherwise it is + * the same protocol as v32, which exchanges since v45 still accept. */ const REFRESH_PROTOCOL_V27 = 27; const REFRESH_PROTOCOL_V32 = 32; +const REFRESH_PROTOCOL_V45 = 45; + +type RefreshProtocolVersion = + | typeof REFRESH_PROTOCOL_V27 + | typeof REFRESH_PROTOCOL_V32 + | typeof REFRESH_PROTOCOL_V45; /** * Which refresh protocol a session speaks. @@ -727,14 +735,14 @@ const REFRESH_PROTOCOL_V32 = 32; */ function refreshProtocolVersionOf( rs: WalletRefreshSession, -): typeof REFRESH_PROTOCOL_V27 | typeof REFRESH_PROTOCOL_V32 { +): RefreshProtocolVersion { const v = rs.refreshProtocolVersion; // Absent means the session was melted before the field existed. if (v == null || v === REFRESH_PROTOCOL_V27) { return REFRESH_PROTOCOL_V27; } - if (v === REFRESH_PROTOCOL_V32) { - return REFRESH_PROTOCOL_V32; + if (v === REFRESH_PROTOCOL_V32 || v === REFRESH_PROTOCOL_V45) { + return v; } // Guessing here would make us reveal secrets that do not match what we // committed to in the melt step, which permanently loses the coin. @@ -759,7 +767,9 @@ type DerivedRefresh = signatures: EddsaSignatureString[]; }) | (DerivedRefreshCommon & { - protocolVersion: typeof REFRESH_PROTOCOL_V32; + protocolVersion: + | typeof REFRESH_PROTOCOL_V32 + | typeof REFRESH_PROTOCOL_V45; /** kappa batch seeds, one per cut-and-choose candidate batch. */ batchSeeds: HashCodeString[]; /** kappa arrays of one transfer public key per fresh coin. */ @@ -920,8 +930,13 @@ export async function deriveRefreshSession( }; const protocolVersion = refreshProtocolVersionOf(refreshSession); switch (protocolVersion) { - case REFRESH_PROTOCOL_V32: { - const derived = await wex.cryptoApi.deriveRefreshSessionV3(req); + case REFRESH_PROTOCOL_V32: + case REFRESH_PROTOCOL_V45: { + const derived = await wex.cryptoApi.deriveRefreshSessionV3({ + ...req, + includeTransferPubsInCommitment: + protocolVersion === REFRESH_PROTOCOL_V45, + }); return { protocolVersion, confirmSig: derived.confirmSig, @@ -1181,9 +1196,11 @@ async function refreshMelt( throw Error("unsupported exchange version"); } const refreshProtocolVersion = - exchangeVer.current >= REFRESH_PROTOCOL_V32 - ? REFRESH_PROTOCOL_V32 - : REFRESH_PROTOCOL_V27; + exchangeVer.current >= REFRESH_PROTOCOL_V45 + ? REFRESH_PROTOCOL_V45 + : exchangeVer.current >= REFRESH_PROTOCOL_V32 + ? REFRESH_PROTOCOL_V32 + : REFRESH_PROTOCOL_V27; const seed = encodeCrock(getRandomBytes(64)); const updatedSession = await wex.runWalletDbTx(async (tx) => { const refreshSession = await tx.getRefreshSession( @@ -1250,7 +1267,7 @@ async function refreshMelt( // Absent for v27; its absence is how the exchange tells the two refresh // protocols apart. transfer_pubs: - derived.protocolVersion === REFRESH_PROTOCOL_V32 + derived.protocolVersion !== REFRESH_PROTOCOL_V27 ? derived.transferPubs : undefined, denoms_h: newDenomsFlat, @@ -2056,7 +2073,7 @@ async function refreshReveal( const req: ExchangeRefreshRevealRequestV2 = { rc: derived.hash, age_commitment: oldCoin.ageCommitmentProof?.commitment?.publicKeys, - ...(derived.protocolVersion === REFRESH_PROTOCOL_V32 + ...(derived.protocolVersion !== REFRESH_PROTOCOL_V27 ? { batch_seeds: derived.batchSeeds.filter(disclosed) } : { signatures: derived.signatures.filter(disclosed) }), }; diff --git a/packages/taler-wallet-core/src/testing-recover-coins.test.ts b/packages/taler-wallet-core/src/testing-recover-coins.test.ts @@ -223,6 +223,8 @@ async function fixture(db: WalletDbHandle) { } else if (path === "melt") { const session = sessions.get(body.refresh_seed)!; assert.deepEqual(body.denoms_h, session.melt.denoms_h); + // Only v27 melts come without transfer public keys. + assert.equal(body.transfer_pubs === undefined, session.version === 27); response = { noreveal_index: noRevealIndex, exchange_pub: k("exchange"), @@ -231,6 +233,7 @@ async function fixture(db: WalletDbHandle) { } else if (path === "reveal-melt") { const s = [...sessions.values()].find((s) => s.melt.rc === body.rc)!; assert.equal((body.batch_seeds ?? body.signatures).length, 2); + assert.equal(body.batch_seeds === undefined, s.version === 27); response = { ev_sigs: s.outputs.map(() => ({ cipher: DenomKeyType.Rsa, @@ -294,7 +297,8 @@ async function fixture(db: WalletDbHandle) { cryptoApi: { signCoinHistoryRequest: async () => ({ sig }), isValidCoinHistory: async () => ({ valid: !badHistory }), - deriveRefreshSessionV3: (r: any) => derive(r, 32), + deriveRefreshSessionV3: (r: any) => + derive(r, r.includeTransferPubsInCommitment ? 45 : 32), deriveRefreshSessionV2: (r: any) => derive(r, 27), isValidMeltConfirmation: async () => ({ valid: !badSignature }), unblindDenominationSignature: async () => ({ @@ -350,7 +354,7 @@ for (const makeDb of runnerFactories) { coin("third", "2"), ]; f.melt(f.root, outputs, 27); - f.melt(outputs[0], [coin("grandchild", "1")]); + f.melt(outputs[0], [coin("grandchild", "1")], 45); const result = await f.run(); assert.equal(result.complete, true, JSON.stringify(result)); assert.equal(result.recoveredAmount, a("4")); diff --git a/packages/taler-wallet-core/src/testing-recover-coins.ts b/packages/taler-wallet-core/src/testing-recover-coins.ts @@ -238,7 +238,7 @@ export async function recoverCoinMelt( }; phase("derive"); let derived: Awaited<ReturnType<typeof deriveRefreshSession>> | undefined; - for (const version of [32, 27]) { + for (const version of [45, 32, 27]) { checkCancelled(wex); session.refreshProtocolVersion = version; const candidate = await deriveRefreshSession( @@ -278,7 +278,7 @@ export async function recoverCoinMelt( blinding_seed: derived.blindingSeed, confirm_sig: derived.confirmSig, coin_evs: derived.planchets.map((batch) => batch.map((c) => c.coinEv)), - ...(derived.protocolVersion === 32 + ...(derived.protocolVersion !== 27 ? { transfer_pubs: derived.transferPubs } : {}), denoms_h: melt.denoms_h, @@ -334,7 +334,7 @@ export async function recoverCoinMelt( body: { rc: derived.hash, age_commitment: oldCoin.ageCommitmentProof?.commitment.publicKeys, - ...(derived.protocolVersion === 32 + ...(derived.protocolVersion !== 27 ? { batch_seeds: derived.batchSeeds.filter(disclose) } : { signatures: derived.signatures.filter(disclose) }), },