commit a53ec7db6421990018d164c09f01514dd27520e2
parent 95e70cad7a1d56ee001064fad3168a78495aef62
Author: Florian Dold <dold@taler.net>
Date: Tue, 28 Jul 2026 15:18:31 +0200
wallet: implement the v32 refresh protocol
Fresh coins are derived via transfer keys again, so publishing the transfer
public keys in the melt step is what makes them recoverable from the exchange's
link data. Sessions record which protocol they melted with, because melt and
reveal must agree on it even if the exchange is upgraded in between.
Issue: https://bugs.taler.net/n/11540
Diffstat:
9 files changed, 525 insertions(+), 40 deletions(-)
diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts
@@ -558,8 +558,11 @@ export interface ExchangeMeltRequestV2 {
// for the new coins to order.
denoms_h: HashCode[];
- // Seed from which the nonces for the n*κ coin candidates are derived
- // from.
+ // Seed for the refresh operation.
+ //
+ // Up to protocol v32, the nonces for the n*κ coin candidates are derived
+ // from it directly. Since v32 it is opaque to the exchange, and the client
+ // derives the κ batch seeds from it and the old coin's private key.
refresh_seed: HashCode;
// Master seed for the Clause-Schnorr R-value
@@ -576,6 +579,15 @@ export interface ExchangeMeltRequestV2 {
// function.
coin_evs: CoinEnvelope[][];
+ // kappa arrays of n ephemeral ECDHE transfer public keys, matching the
+ // respective entries in coin_evs. They let the owner of the old coin
+ // recover the fresh coins from the exchange's link data.
+ //
+ // @since protocol **v32**. The exchange treats a melt request without this
+ // field as a v27 melt, where the fresh coins are instead derived from
+ // signatures over the refresh nonces.
+ transfer_pubs?: EddsaPublicKey[][];
+
// Signature by the coin over TALER_RefreshMeltCoinAffirmationPS.
confirm_sig: EddsaSignatureString;
}
@@ -957,7 +969,18 @@ export interface ExchangeRefreshRevealRequestV2 {
// over Hash1a("Refresh", Cp, r, i), where Cp is the melted coin's public key,
// r is the public refresh nonce from the metling step and i runs over the
// _disclosed_ kappa-1 indices.
- signatures: EddsaSignature[];
+ //
+ // @deprecated in protocol **v32**, where batch_seeds is sent instead.
+ // Exactly one of signatures and batch_seeds must be present, and it must
+ // match what the melt step committed to.
+ signatures?: EddsaSignature[];
+
+ // The disclosed kappa-1 batch seeds from which the transfer private keys of
+ // the corresponding coin candidates were derived, in the order of the
+ // _disclosed_ kappa-1 indices.
+ //
+ // @since protocol **v32**.
+ batch_seeds?: HashCode[];
// IFF the denomination of the old coin had support for age restriction,
// the client MUST provide the original age commitment, i. e. the
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -109,8 +109,10 @@ import {
DecryptContractResponse,
DerivedRefreshSession,
DerivedRefreshSessionV2,
+ DerivedRefreshSessionV3,
DeriveRefreshSessionRequest,
DeriveRefreshSessionRequestV2,
+ DeriveRefreshSessionRequestV3,
EncryptContractForDepositRequest,
EncryptContractForDepositResponse,
EncryptContractRequest,
@@ -237,6 +239,10 @@ export interface TalerCryptoInterface {
req: DeriveRefreshSessionRequestV2,
): Promise<DerivedRefreshSessionV2>;
+ deriveRefreshSessionV3(
+ req: DeriveRefreshSessionRequestV3,
+ ): Promise<DerivedRefreshSessionV3>;
+
hashString(req: HashStringRequest): Promise<HashStringResult>;
signCoinLink(req: SignCoinLinkRequest): Promise<EddsaSigningResult>;
@@ -565,6 +571,11 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<DerivedRefreshSessionV2> {
throw new Error("Function not implemented.");
},
+ deriveRefreshSessionV3: function (
+ req: DeriveRefreshSessionRequestV3,
+ ): Promise<DerivedRefreshSessionV3> {
+ throw new Error("Function not implemented.");
+ },
rsaVerifyDirect: function (
req: RsaVerificationRequest,
): Promise<ValidationResult> {
@@ -1765,6 +1776,207 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
};
},
+ /**
+ * Derive a refresh session for the v32 refresh protocol.
+ *
+ * Same shape as {@link deriveRefreshSessionV2}, but the fresh coins of each
+ * cut-and-choose candidate batch are derived via transfer keys again: the
+ * public seed expands (under the old coin's private key) into kappa batch
+ * seeds, each batch seed expands into one transfer private key per fresh
+ * coin, and the planchet secret of a coin is derived from the ECDH between
+ * that transfer key and the old coin. Publishing the transfer public keys
+ * 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.
+ */
+ async deriveRefreshSessionV3(
+ tci: TalerCryptoInterfaceR,
+ req: DeriveRefreshSessionRequestV3,
+ ): Promise<DerivedRefreshSessionV3> {
+ const {
+ newCoinDenoms,
+ feeRefresh,
+ kappa,
+ meltCoinDenomPubHash,
+ meltCoinPriv,
+ meltCoinPub,
+ sessionPublicSeed,
+ } = req;
+
+ const currency = Amounts.currencyOf(newCoinDenoms[0].value);
+ let valueWithFee = Amounts.zeroOfCurrency(currency);
+
+ // Melt fee of old coin.
+ valueWithFee = Amounts.add(valueWithFee, feeRefresh).amount;
+
+ // Coin value and withdrawal fees for new coins.
+ for (const ncd of newCoinDenoms) {
+ const t = Amounts.add(ncd.value, ncd.feeWithdraw).amount;
+ valueWithFee = Amounts.add(
+ valueWithFee,
+ Amounts.mult(t, ncd.count).amount,
+ ).amount;
+ }
+
+ const numDenoms = newCoinDenoms.length;
+ let numCoins = 0;
+ for (let i = 0; i < numDenoms; i++) {
+ numCoins += newCoinDenoms[i].count;
+ }
+
+ // See TALER_refresh_expand_seed_to_kappa_batch_seeds in the C impl.
+ const batchSeedsBytes = kdfKw({
+ outputLength: 64 * kappa,
+ salt: stringToBytes("refresh-batch-seeds"),
+ ikm: decodeCrock(sessionPublicSeed),
+ info: decodeCrock(meltCoinPriv),
+ });
+
+ const planchetsForGammas: RefreshPlanchetInfo[][] = [];
+ const transferPubsForGammas: EddsaPublicKeyString[][] = [];
+ const batchSeeds: HashCodeString[] = [];
+
+ const sessionHc = createHashContext();
+
+ sessionHc.update(decodeCrock(sessionPublicSeed));
+ const blindingSeed = new Uint8Array(32);
+ // For CS, we'd need to also read the real blinding_seed into sessionHc.
+ sessionHc.update(blindingSeed);
+ sessionHc.update(decodeCrock(meltCoinPub));
+ sessionHc.update(bufferFromAmount(valueWithFee));
+
+ for (let i = 0; i < kappa; i++) {
+ const planchets: RefreshPlanchetInfo[] = [];
+ const transferPubs: EddsaPublicKeyString[] = [];
+ const batchSeed = batchSeedsBytes.slice(i * 64, i * 64 + 64);
+ batchSeeds.push(encodeCrock(batchSeed));
+
+ // See TALER_refresh_expand_batch_seed_to_transfer_private_keys.
+ const transferPrivsBytes = kdfKw({
+ outputLength: 32 * numCoins,
+ salt: stringToBytes("refresh-transfer-private-keys"),
+ ikm: batchSeed,
+ });
+
+ const planchetsHc = createHashContext();
+
+ for (let j = 0; j < numDenoms; j++) {
+ const denomSel = newCoinDenoms[j];
+ for (let k = 0; k < newCoinDenoms[j].count; k++) {
+ const coinIndex = planchets.length;
+ const transferPriv = encodeCrock(
+ transferPrivsBytes.slice(32 * coinIndex, 32 * coinIndex + 32),
+ );
+ const transferPubRes = await tci.ecdheGetPublic(tci, {
+ priv: transferPriv,
+ });
+ transferPubs.push(transferPubRes.pub);
+ const transferSecretRes = await tci.keyExchangeEcdheEddsa(tci, {
+ ecdhePriv: transferPriv,
+ eddsaPub: meltCoinPub,
+ });
+ // See TALER_transfer_secret_to_planchet_secret in the C impl.
+ const myPlanchetSecret = kdfKw({
+ ikm: decodeCrock(transferSecretRes.h),
+ outputLength: 32,
+ salt: bufferForUint32(coinIndex),
+ info: stringToBytes("taler-coin-derivation"),
+ });
+ const fresh: FreshCoinEncoded = await tci.setupRefreshPlanchetV2(
+ tci,
+ {
+ coinNumber: coinIndex,
+ refreshPlanchetSecret: encodeCrock(myPlanchetSecret),
+ },
+ );
+ let newAc: AgeCommitmentProof | undefined = undefined;
+ let newAch: HashCodeString | undefined = undefined;
+ if (req.meltCoinAgeCommitmentProof) {
+ const ageCommitmentSalt = kdfKw({
+ ikm: myPlanchetSecret,
+ outputLength: 64,
+ salt: stringToBytes("age commitment"),
+ });
+ newAc = await AgeRestriction.commitmentDerive(
+ req.meltCoinAgeCommitmentProof,
+ ageCommitmentSalt,
+ );
+ newAch = AgeRestriction.hashCommitment(newAc.commitment);
+ }
+ const coinPubHash = hashCoinPub(fresh.coinPub, newAch);
+ if (denomSel.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error("unsupported cipher, can't create refresh session");
+ }
+ const blindResult = await tci.rsaBlind(tci, {
+ bks: fresh.bks,
+ hm: encodeCrock(coinPubHash),
+ pub: denomSel.denomPub.rsa_public_key,
+ });
+ const coinEv: CoinEnvelope = {
+ cipher: DenomKeyType.Rsa,
+ rsa_blinded_planchet: blindResult.blinded,
+ };
+ const coinEvHash = hashCoinEv(
+ coinEv,
+ encodeCrock(hashDenomPub(denomSel.denomPub)),
+ );
+ const planchet: RefreshPlanchetInfo = {
+ blindingKey: fresh.bks,
+ coinEv,
+ coinPriv: fresh.coinPriv,
+ coinPub: fresh.coinPub,
+ coinEvHash: encodeCrock(coinEvHash),
+ maxAge: req.meltCoinMaxAge,
+ ageCommitmentProof: newAc,
+ };
+ planchets.push(planchet);
+ planchetsHc.update(coinEvHash);
+ }
+ }
+
+ sessionHc.update(planchetsHc.finish());
+
+ planchetsForGammas.push(planchets);
+ transferPubsForGammas.push(transferPubs);
+ }
+
+ const sessionHash = sessionHc.finish();
+ let hAgeCommitment: Uint8Array;
+ if (req.meltCoinAgeCommitmentProof) {
+ hAgeCommitment = decodeCrock(
+ AgeRestriction.hashCommitment(
+ req.meltCoinAgeCommitmentProof.commitment,
+ ),
+ );
+ } else {
+ hAgeCommitment = new Uint8Array(32);
+ }
+ const confirmData = buildSigPS(TalerSignaturePurpose.WALLET_COIN_MELT)
+ .put(sessionHash)
+ .put(decodeCrock(meltCoinDenomPubHash))
+ .put(hAgeCommitment)
+ .put(bufferFromAmount(valueWithFee))
+ .put(bufferFromAmount(feeRefresh))
+ .build();
+
+ const confirmSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(confirmData),
+ priv: meltCoinPriv,
+ });
+
+ return {
+ batchSeeds,
+ transferPubs: transferPubsForGammas,
+ planchets: planchetsForGammas,
+ confirmSig: confirmSigResp.sig,
+ hash: encodeCrock(sessionHash),
+ meltCoinPub: meltCoinPub,
+ meltValueWithFee: valueWithFee,
+ };
+ },
+
async deriveRefreshSession(
tci: TalerCryptoInterfaceR,
req: DeriveRefreshSessionRequest,
diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
@@ -72,7 +72,8 @@ export interface DeriveRefreshSessionRequest {
* Request to derive a refresh session from the refresh session
* public seed.
*
- * This version is for the improved, PQC-compatible refresh protocol.
+ * This version is for the improved, PQC-compatible refresh protocol
+ * of exchange protocol v27.
*/
export interface DeriveRefreshSessionRequestV2 {
sessionPublicSeed: string;
@@ -86,6 +87,25 @@ export interface DeriveRefreshSessionRequestV2 {
feeRefresh: AmountJson;
}
+/**
+ * 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.
+ */
+export interface DeriveRefreshSessionRequestV3 {
+ sessionPublicSeed: string;
+ kappa: number;
+ meltCoinPub: string;
+ meltCoinPriv: string;
+ meltCoinDenomPubHash: string;
+ meltCoinMaxAge: number;
+ meltCoinAgeCommitmentProof?: AgeCommitmentProof;
+ newCoinDenoms: RefreshNewDenomInfo[];
+ feeRefresh: AmountJson;
+}
+
export interface DerivedRefreshSession {
/**
* Public key that's being melted in this session.
@@ -155,6 +175,46 @@ export interface DerivedRefreshSessionV2 {
meltValueWithFee: AmountJson;
}
+export interface DerivedRefreshSessionV3 {
+ /**
+ * Public key that's being melted in this session.
+ */
+ meltCoinPub: string;
+
+ /**
+ * Signature to confirm the melting.
+ */
+ confirmSig: string;
+
+ /**
+ * Planchets for each cut-and-choose instance.
+ */
+ planchets: RefreshPlanchetInfo[][];
+
+ /**
+ * kappa batch seeds, one per cut-and-choose instance. The kappa-1 seeds
+ * that are not the noreveal index are disclosed in the reveal step.
+ */
+ batchSeeds: HashCodeString[];
+
+ /**
+ * Transfer public keys, one per fresh coin for each of the kappa
+ * cut-and-choose instances. Sent in the melt step so that the owner of the
+ * old coin can recover the fresh coins from the exchange's link data.
+ */
+ transferPubs: EddsaPublicKeyString[][];
+
+ /**
+ * Hash of the session.
+ */
+ hash: string;
+
+ /**
+ * Exact value that is being melted.
+ */
+ meltValueWithFee: AmountJson;
+}
+
export interface SignTrackTransactionRequest {
contractTermsHash: string;
wireHash: string;
diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts
@@ -495,6 +495,17 @@ export interface WalletRefreshSession {
sessionPublicSeed?: string;
/**
+ * Exchange protocol version whose refresh protocol this session speaks,
+ * fixed when the melt request is prepared.
+ *
+ * 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.
+ */
+ refreshProtocolVersion?: number;
+
+ /**
* Sum of the value of denominations we want
* to withdraw in this session, without fees.
*/
diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts
@@ -85,7 +85,7 @@
*
* Bump this when adding a migration to {@link schemaMigrations}.
*/
-export const SQLITE_SCHEMA_VERSION = 4;
+export const SQLITE_SCHEMA_VERSION = 5;
/**
* A single, ordered schema evolution step.
@@ -485,18 +485,21 @@ CREATE INDEX IF NOT EXISTS slates_by_purchase_choice_output_repeat
ON slates (purchase_id, choice_index, output_index, repeat_index);
CREATE TABLE IF NOT EXISTS refresh_sessions (
- refresh_group_id TEXT NOT NULL
+ refresh_group_id TEXT NOT NULL
REFERENCES refresh_groups(refresh_group_id)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED,
- coin_index INTEGER NOT NULL,
- session_public_seed BLOB,
- amount_refresh_output TEXT NOT NULL,
+ coin_index INTEGER NOT NULL,
+ session_public_seed BLOB,
+ -- Exchange protocol version of the refresh protocol the session melted
+ -- with; NULL means the v27 one.
+ refresh_protocol_version INTEGER,
+ amount_refresh_output TEXT NOT NULL,
-- JSON: { denomPubHash, count }[]
- new_denoms TEXT NOT NULL,
- noreveal_index INTEGER,
+ new_denoms TEXT NOT NULL,
+ noreveal_index INTEGER,
-- JSON: TalerErrorDetail
- last_error TEXT,
+ last_error TEXT,
PRIMARY KEY (refresh_group_id, coin_index)
);
diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts
@@ -375,10 +375,17 @@ export async function refreshCoin(req: {
cryptoApi: TalerCryptoInterface;
oldCoin: CoinInfo;
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.
+ */
+ protocolVersion?: 27 | 32;
}): Promise<void> {
const { cryptoApi, oldCoin, http } = req;
+ const protocolVersion = req.protocolVersion ?? 32;
const refreshSessionSeed = encodeCrock(getRandomBytes(64));
- const session = await cryptoApi.deriveRefreshSessionV2({
+ const sessionReq = {
feeRefresh: Amounts.parseOrThrow(oldCoin.feeRefresh),
kappa: 3,
meltCoinDenomPubHash: oldCoin.denomPubHash,
@@ -393,7 +400,11 @@ export async function refreshCoin(req: {
value: x.value,
})),
meltCoinMaxAge: oldCoin.maxAge,
- });
+ };
+ const session =
+ protocolVersion === 32
+ ? await cryptoApi.deriveRefreshSessionV3(sessionReq)
+ : await cryptoApi.deriveRefreshSessionV2(sessionReq);
const meltReqBody: ExchangeMeltRequestV2 = {
old_coin_pub: oldCoin.coinPub,
@@ -403,6 +414,7 @@ export async function refreshCoin(req: {
refresh_seed: refreshSessionSeed,
confirm_sig: session.confirmSig,
coin_evs: session.planchets.map((x) => x.map((y) => y.coinEv)),
+ transfer_pubs: "transferPubs" in session ? session.transferPubs : undefined,
denoms_h: req.newDenoms.map((x) => x.denomPubHash),
value_with_fee: Amounts.stringify(session.meltValueWithFee),
};
@@ -419,8 +431,14 @@ export async function refreshCoin(req: {
const revealRequest: ExchangeRefreshRevealRequestV2 = {
rc: session.hash,
- signatures: session.signatures.filter((v, i) => i != norevealIndex),
// age_commitment: ...
+ ...("batchSeeds" in session
+ ? {
+ batch_seeds: session.batchSeeds.filter((v, i) => i != norevealIndex),
+ }
+ : {
+ signatures: session.signatures.filter((v, i) => i != norevealIndex),
+ }),
};
succeedOrThrow(await exchangeClient.postRevealMelt({ body: revealRequest }));
diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts
@@ -3248,6 +3248,33 @@ export const conformanceCases: ConformanceCase[] = [
},
{
+ name: "refresh session: round trip with the melt fields set and unset",
+ async run(t, runner) {
+ const fresh = makeRefreshSession("rs-melt", 0);
+ const melted = makeRefreshSession("rs-melt", 1);
+ melted.sessionPublicSeed = ckh("seed-melt");
+ melted.refreshProtocolVersion = 32;
+ melted.norevealIndex = 2;
+ await runner.runReadWriteTx(async (tx) => {
+ await seedRefreshGroup(tx, "rs-melt");
+ await tx.upsertRefreshSession(fresh);
+ await tx.upsertRefreshSession(melted);
+ });
+ const gotFresh = await runner.runReadWriteTx((tx) =>
+ tx.getRefreshSession("rs-melt", 0),
+ );
+ t.deepEqual(withoutUndefined(gotFresh), withoutUndefined(fresh));
+ // An absent protocol version means the v27 refresh protocol, so it must
+ // not come back as some other value.
+ t.equal(gotFresh?.refreshProtocolVersion, undefined);
+ const gotMelted = await runner.runReadWriteTx((tx) =>
+ tx.getRefreshSession("rs-melt", 1),
+ );
+ t.deepEqual(withoutUndefined(gotMelted), withoutUndefined(melted));
+ },
+ },
+
+ {
name: "recoup group: round trip, by exchange and active range",
async run(t, runner) {
const rc = makeRecoupGroup("rc-1", "https://rex/");
diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts
@@ -4722,6 +4722,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
...(row.session_public_seed != null
? { sessionPublicSeed: dbToCrock(row.session_public_seed) }
: undefined),
+ ...(row.refresh_protocol_version != null
+ ? { refreshProtocolVersion: num(row.refresh_protocol_version) }
+ : undefined),
...(row.noreveal_index != null
? { norevealIndex: num(row.noreveal_index) }
: undefined),
@@ -4747,10 +4750,12 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
await this.run(
`INSERT INTO refresh_sessions (
refresh_group_id, coin_index, session_public_seed,
- amount_refresh_output, new_denoms, noreveal_index, last_error
- ) VALUES ($id, $idx, $seed, $amt, $nd, $nri, $err)
+ refresh_protocol_version, amount_refresh_output, new_denoms,
+ noreveal_index, last_error
+ ) VALUES ($id, $idx, $seed, $rpv, $amt, $nd, $nri, $err)
ON CONFLICT(refresh_group_id, coin_index) DO UPDATE SET
session_public_seed = excluded.session_public_seed,
+ refresh_protocol_version = excluded.refresh_protocol_version,
amount_refresh_output = excluded.amount_refresh_output,
new_denoms = excluded.new_denoms,
noreveal_index = excluded.noreveal_index,
@@ -4759,6 +4764,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction {
id: rec.refreshGroupId,
idx: rec.coinIndex,
seed: optCrockToDb(rec.sessionPublicSeed),
+ rpv: rec.refreshProtocolVersion ?? null,
amt: rec.amountRefreshOutput,
nd: jsonToDb(rec.newDenoms),
nri: rec.norevealIndex ?? null,
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
@@ -36,6 +36,8 @@ import {
DenominationInfo,
DenomKeyType,
Duration,
+ EddsaPublicKeyString,
+ EddsaSignatureString,
encodeCrock,
ExchangeMeltRequestV2,
ExchangeRefreshRevealRequestV2,
@@ -575,6 +577,123 @@ function getRefreshRequestTimeout(rg: WalletRefreshGroup): Duration {
}
/**
+ * Refresh protocol variants the wallet speaks, named after the exchange
+ * protocol version that introduced them.
+ *
+ * In v27, the fresh coins of a cut-and-choose candidate batch are derived from
+ * a signature by the old coin over a nonce, and the reveal step discloses
+ * 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.
+ */
+const REFRESH_PROTOCOL_V27 = 27;
+const REFRESH_PROTOCOL_V32 = 32;
+
+/**
+ * Which refresh protocol a session speaks.
+ *
+ * Fixed when the melt request is prepared and stored with the session, since
+ * melt and reveal must agree on it even if the exchange is upgraded in
+ * between.
+ */
+function refreshProtocolVersionOf(
+ rs: WalletRefreshSession,
+): typeof REFRESH_PROTOCOL_V27 | typeof REFRESH_PROTOCOL_V32 {
+ 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;
+ }
+ // Guessing here would make us reveal secrets that do not match what we
+ // committed to in the melt step, which permanently loses the coin.
+ throw Error(
+ `refresh session ${rs.refreshGroupId}/${rs.coinIndex} uses unknown refresh protocol version ${v}`,
+ );
+}
+
+interface DerivedRefreshCommon {
+ confirmSig: string;
+ planchets: RefreshPlanchetInfo[][];
+ /** The refresh commitment, i.e. the session hash. */
+ hash: string;
+ meltValueWithFee: AmountJson;
+}
+
+type DerivedRefresh =
+ | (DerivedRefreshCommon & {
+ protocolVersion: typeof REFRESH_PROTOCOL_V27;
+ /** kappa signatures by the old coin's private key. */
+ signatures: EddsaSignatureString[];
+ })
+ | (DerivedRefreshCommon & {
+ protocolVersion: typeof REFRESH_PROTOCOL_V32;
+ /** kappa batch seeds, one per cut-and-choose candidate batch. */
+ batchSeeds: HashCodeString[];
+ /** kappa arrays of one transfer public key per fresh coin. */
+ transferPubs: EddsaPublicKeyString[][];
+ });
+
+/**
+ * Re-derive everything about a refresh session from the stored seed, using the
+ * refresh protocol the session melted with.
+ *
+ * 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(
+ wex: WalletExecutionContext,
+ refreshSession: WalletRefreshSession,
+ oldCoin: WalletCoin,
+ oldDenom: DenominationInfo,
+ newCoinDenoms: RefreshNewDenomInfo[],
+): Promise<DerivedRefresh> {
+ checkLogicInvariant(refreshSession.sessionPublicSeed != null);
+ const req = {
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ meltCoinMaxAge: oldCoin.maxAge,
+ meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ newCoinDenoms,
+ sessionPublicSeed: refreshSession.sessionPublicSeed,
+ };
+ const protocolVersion = refreshProtocolVersionOf(refreshSession);
+ switch (protocolVersion) {
+ case REFRESH_PROTOCOL_V32: {
+ const derived = await wex.cryptoApi.deriveRefreshSessionV3(req);
+ return {
+ protocolVersion,
+ confirmSig: derived.confirmSig,
+ planchets: derived.planchets,
+ hash: derived.hash,
+ meltValueWithFee: derived.meltValueWithFee,
+ batchSeeds: derived.batchSeeds,
+ transferPubs: derived.transferPubs,
+ };
+ }
+ case REFRESH_PROTOCOL_V27: {
+ const derived = await wex.cryptoApi.deriveRefreshSessionV2(req);
+ return {
+ protocolVersion,
+ confirmSig: derived.confirmSig,
+ planchets: derived.planchets,
+ hash: derived.hash,
+ meltValueWithFee: derived.meltValueWithFee,
+ signatures: derived.signatures,
+ };
+ }
+ default:
+ assertUnreachable(protocolVersion);
+ }
+}
+
+/**
* Run the melt step of a refresh session.
*
* If the melt step succeeds or fails permanently,
@@ -650,7 +769,7 @@ async function refreshMelt(
return true;
}
- // Make sure that we have a seed.
+ // Make sure that we have a seed and know which refresh protocol to speak.
if (d.refreshSession.sessionPublicSeed == null) {
const exchange = await fetchFreshExchange(wex, d.oldCoin.exchangeBaseUrl);
const exchangeVer = LibtoolVersion.parseVersion(
@@ -660,6 +779,10 @@ async function refreshMelt(
if (exchangeVer.current < 27) {
throw Error("unsupported exchange version");
}
+ const refreshProtocolVersion =
+ 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(
@@ -673,6 +796,7 @@ async function refreshMelt(
return refreshSession;
}
refreshSession.sessionPublicSeed = seed;
+ refreshSession.refreshProtocolVersion = refreshProtocolVersion;
await tx.upsertRefreshSession(refreshSession);
return refreshSession;
});
@@ -692,18 +816,13 @@ async function refreshMelt(
oldCoin.ageCommitmentProof.commitment,
);
}
- // New melt protocol.
- const derived = await wex.cryptoApi.deriveRefreshSessionV2({
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
- meltCoinMaxAge: oldCoin.maxAge,
- meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ const derived = await deriveRefreshSession(
+ wex,
+ refreshSession,
+ oldCoin,
+ oldDenom,
newCoinDenoms,
- sessionPublicSeed: refreshSession.sessionPublicSeed,
- });
+ );
// Wallet stores new denoms run-length encoded,
// we need to expand the list of denominations
@@ -724,6 +843,12 @@ async function refreshMelt(
refresh_seed: refreshSession.sessionPublicSeed,
confirm_sig: derived.confirmSig,
coin_evs: derived.planchets.map((x) => x.map((y) => y.coinEv)),
+ // Absent for v27; its absence is how the exchange tells the two refresh
+ // protocols apart.
+ transfer_pubs:
+ derived.protocolVersion === REFRESH_PROTOCOL_V32
+ ? derived.transferPubs
+ : undefined,
denoms_h: newDenomsFlat,
value_with_fee: Amounts.stringify(derived.meltValueWithFee),
};
@@ -1070,21 +1195,21 @@ async function refreshReveal(
checkLogicInvariant(refreshSession.sessionPublicSeed != null);
- const derived = await wex.cryptoApi.deriveRefreshSessionV2({
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- sessionPublicSeed: refreshSession.sessionPublicSeed,
- feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ const derived = await deriveRefreshSession(
+ wex,
+ refreshSession,
+ oldCoin,
+ oldDenom,
newCoinDenoms,
- meltCoinMaxAge: oldCoin.maxAge,
- meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
- });
+ );
+ // Everything but the candidate batch the exchange told us to keep secret.
+ const disclosed = (_: unknown, i: number): boolean => i !== norevealIndex;
const req: ExchangeRefreshRevealRequestV2 = {
rc: derived.hash,
- signatures: derived.signatures.filter((v, i) => i != norevealIndex),
age_commitment: oldCoin.ageCommitmentProof?.commitment?.publicKeys,
+ ...(derived.protocolVersion === REFRESH_PROTOCOL_V32
+ ? { batch_seeds: derived.batchSeeds.filter(disclosed) }
+ : { signatures: derived.signatures.filter(disclosed) }),
};
const exchangeClient = walletExchangeClient(
oldCoin.exchangeBaseUrl,