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:
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) }),
},