taler-typescript-core

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

commit 4ea71a9400d39fa7f6aadd0c9ba7f2116a8b2990
parent 7841c16a94b343a859c690a82bac2cc5e36053a1
Author: Florian Dold <dold@taler.net>
Date:   Mon, 10 Aug 2026 11:05:24 +0200

wallet-core: support Clause-Schnorr blind signatures

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-clause-schnorr.ts | 12++++++++++++
Mpackages/taler-util/src/http-client/exchange-client.ts | 32++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/taler-crypto.ts | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-util/src/types-taler-exchange.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-util/src/types-taler-wallet.ts | 7+++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 480+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mpackages/taler-wallet-core/src/crypto/cryptoTypes.ts | 5+++++
Mpackages/taler-wallet-core/src/db-common.ts | 5+++++
Mpackages/taler-wallet-core/src/db-sqlite-migrations.test.ts | 17+++++++++--------
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 13+++++++++++--
Mpackages/taler-wallet-core/src/dbtx-bench.ts | 1+
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 2++
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 14++++++++++----
Mpackages/taler-wallet-core/src/exchanges.ts | 36++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/pay-merchant.ts | 2++
Mpackages/taler-wallet-core/src/refresh.ts | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/withdraw.ts | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
17 files changed, 780 insertions(+), 209 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-clause-schnorr.ts b/packages/taler-harness/src/integrationtests/test-clause-schnorr.ts @@ -73,6 +73,18 @@ export async function runClauseSchnorrTest(t: GlobalTestState) { }); await wres.withdrawalFinishedCond; + // Exercise the CS refresh protocol explicitly. Payments below can trigger + // refreshes as a side effect, but that depends on denomination selection. + const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {}); + const coinToRefresh = coinDump.coins[0]; + if (!coinToRefresh) { + throw Error("Clause-Schnorr withdrawal did not produce a coin"); + } + await walletClient.call(WalletApiOperation.ForceRefresh, { + refreshCoinSpecs: [{ coinPub: coinToRefresh.coinPub }], + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + const order: TalerMerchantApi.Order = { summary: "Buy me!", amount: "TESTKUDOS:5", diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -58,6 +58,8 @@ import { AmlDecisionsResponse, AvailableMeasureSummary, BatchDepositSuccess, + BlindingPrepareRequest, + BlindingPrepareResponse, EventCounter, ExchangeBatchDepositRequest, ExchangeGetContractResponse, @@ -97,6 +99,7 @@ import { WalletKycRequest, codecForAccountKycStatus, codecForBatchDepositSuccess, + codecForBlindingPrepareResponse, codecForAmlDecisionsAccounts, codecForAmlDecisionsResponse, codecForAmlKycAttributes, @@ -1862,6 +1865,35 @@ export class TalerExchangeHttpClient { } } + async postBlindingPrepare(args: { + body: BlindingPrepareRequest; + }): Promise< + | OperationOk<BlindingPrepareResponse> + | OperationFail<HttpStatusCode.BadRequest> + | OperationFail<HttpStatusCode.NotFound> + | OperationFail<HttpStatusCode.Gone> + | OperationFail<HttpStatusCode.PayloadTooLarge> + | OperationFail<HttpStatusCode.InternalServerError> + > { + const url = new URL(`blinding-prepare`, this.baseUrl); + const resp = await this.fetch(url, { + method: "POST", + body: args.body, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForBlindingPrepareResponse()); + case HttpStatusCode.BadRequest: + case HttpStatusCode.NotFound: + case HttpStatusCode.Gone: + case HttpStatusCode.PayloadTooLarge: + case HttpStatusCode.InternalServerError: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + /** * Request: POST /melt * diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts @@ -690,7 +690,7 @@ export function deriveSecrets(bseed: Uint8Array): CsBlindingSecrets { * calculation of the blinded public point R in CS * @param csPub denomination publik key * @param secrets client blinding secrets - * @param rPub public R received from /csr API + * @param rPub public R received from the exchange */ export async function calcRBlind( csPub: Uint8Array, @@ -738,23 +738,38 @@ function csFDH( } /** - * blinding seed derived from coin private key - * @param coinPriv private key of the corresponding coin - * @param rPub public R received from /csr API + * Blinding seed derived from the planchet master secret and exchange R-pair. + * @param planchetMasterSecret master secret of the corresponding planchet + * @param rPub public R-pair received from /blinding-prepare * @returns blinding seed */ export function deriveBSeed( - coinPriv: Uint8Array, + planchetMasterSecret: Uint8Array, rPub: [Uint8Array, Uint8Array], ): Uint8Array { - const outLen = 32; - const salt = stringToBytes("b-seed"); - const ikm = typedArrayConcat([coinPriv, rPub[0], rPub[1]]); - return kdf(outLen, ikm, salt); + return kdf( + 32, + planchetMasterSecret, + stringToBytes("bseed"), + typedArrayConcat(rPub), + ); +} + +/** Derive a CS coin private key from its planchet secret and exchange R-pair. */ +export function deriveCsCoinPriv( + planchetMasterSecret: Uint8Array, + rPub: [Uint8Array, Uint8Array], +): Uint8Array { + return kdf( + 32, + planchetMasterSecret, + stringToBytes("coin"), + typedArrayConcat(rPub), + ); } /** - * Derive withdraw nonce, used in /csr request + * Derive a Clause-Schnorr withdrawal nonce. * Note: In withdraw protocol, the nonce is chosen randomly * @param coinPriv coin private key * @returns nonce @@ -766,9 +781,58 @@ export function deriveWithdrawNonce(coinPriv: Uint8Array): Uint8Array { } /** - * Blind operation for CS signatures, used after /csr call + * Derive a unique blinding master seed for a one-coin withdrawal request. + * Keeping CS withdrawals one coin per request makes retries independent and + * avoids ever reusing a Clause-Schnorr nonce across partially completed + * wallet withdrawal batches. + */ +export function deriveCsWithdrawBlindingSeed( + withdrawMasterSeed: Uint8Array, + coinIndex: number, +): Uint8Array { + const index = new Uint8Array(4); + new DataView(index.buffer).setUint32(0, coinIndex); + return kdf( + 32, + withdrawMasterSeed, + stringToBytes("withdraw-coin-blinding"), + index, + ); +} + +/** Derive the blinding seed used by a refresh operation. */ +export function deriveCsRefreshBlindingSeed( + refreshMasterSeed: Uint8Array, + oldCoinPriv: Uint8Array, +): Uint8Array { + return kdf( + 32, + oldCoinPriv, + stringToBytes("refresh-blinding"), + refreshMasterSeed, + ); +} + +/** Derive the public nonce input committed to a CS coin envelope. */ +export function deriveCsNonce( + blindingSeed: Uint8Array, + operation: "withdraw" | "melt", + coinIndex: number, +): Uint8Array { + const index = new Uint8Array(4); + new DataView(index.buffer).setUint32(0, coinIndex); + return kdf( + 32, + stringToBytes(operation === "melt" ? "refresh-n" : "withdraw-n"), + index, + blindingSeed, + ); +} + +/** + * Blind operation for CS signatures, used after /blinding-prepare. * @param bseed blinding seed to derive blinding secrets - * @param rPub public R received from /csr + * @param rPub public R received from /blinding-prepare * @param csPub denomination public key * @param hm message to blind * @returns two blinded c @@ -792,7 +856,7 @@ export async function csBlind( /** * Unblind operation to unblind the signature * @param bseed seed to derive secrets - * @param rPub public R received from /csr + * @param rPub public R received from /blinding-prepare * @param csPub denomination public key * @param b returned from exchange to select c * @param csSig blinded signature @@ -904,6 +968,11 @@ export function hashCoinEvInner( case DenomKeyType.Rsa: hashState.update(decodeCrock(coinEv.rsa_blinded_planchet)); return; + case DenomKeyType.ClauseSchnorr: + hashState.update(decodeCrock(coinEv.cs_blinded_c0)); + hashState.update(decodeCrock(coinEv.cs_blinded_c1)); + hashState.update(decodeCrock(coinEv.cs_nonce)); + return; default: throw new Error(); } diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -19,6 +19,7 @@ import { codecForAmountString } from "./amounts.js"; import { Codec, + DecodingError, buildCodecForObject, buildCodecForUnion, codecForAny, @@ -32,6 +33,7 @@ import { codecForStringURL, codecOptional, codecOptionalDefault, + renderContext, } from "./codec.js"; import { strcmp } from "./helpers.js"; import { @@ -283,13 +285,21 @@ export interface RecoupConfirmation { old_coin_pub?: string; } -export type UnblindedDenominationSignature = RsaUnblindedSignature; +export type UnblindedDenominationSignature = + | RsaUnblindedSignature + | CsUnblindedSignature; export interface RsaUnblindedSignature { cipher: DenomKeyType.Rsa; rsa_signature: string; } +export interface CsUnblindedSignature { + cipher: DenomKeyType.ClauseSchnorr; + cs_signature_r: string; + cs_signature_s: string; +} + /** * Deposit permission for a single coin. */ @@ -928,9 +938,50 @@ export interface CoinEnvelopeRsa { export interface CoinEnvelopeCs { cipher: DenomKeyType.ClauseSchnorr; - // FIXME: add remaining fields + cs_nonce: string; + cs_blinded_c0: string; + cs_blinded_c1: string; +} + +export interface BlindingInputParameter { + coin_offset: number; + denom_pub_hash: HashCodeString; +} + +export interface BlindingPrepareRequest { + cipher: DenomKeyType.ClauseSchnorr; + operation: "withdraw" | "melt"; + seed: string; + nks: BlindingInputParameter[]; +} + +export interface BlindingPrepareResponse { + cipher: DenomKeyType.ClauseSchnorr; + r_pubs: [string, string][]; } +const codecForCsRPair = (): Codec<[string, string]> => ({ + decode(x, c) { + const values = codecForList(codecForString()).decode(x, c); + if (values.length !== 2) { + throw new DecodingError( + `expected Clause-Schnorr R-pair at ${renderContext(c)}`, + ); + } + return [values[0], values[1]]; + }, +}); + +export const codecForBlindingPrepareResponse = + (): Codec<BlindingPrepareResponse> => + buildCodecForObject<BlindingPrepareResponse>() + .property("cipher", codecForConstString(DenomKeyType.ClauseSchnorr)) + .property( + "r_pubs", + codecForList(codecForCsRPair()), + ) + .build("BlindingPrepareResponse"); + export interface ExchangeLegacyWithdrawRequest { denom_pub_hash: HashCodeString; reserve_sig: EddsaSignatureString; @@ -3357,6 +3408,8 @@ export interface ExchangeWithdrawRequest { // age restriction. max_age?: number; + blinding_seed?: string; + // Array of blinded coin envelopes of type CoinEnvelope. // If max_age is not set, MUST be n entries. // If max_age is set, MUST be n*kappa entries, diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -89,6 +89,7 @@ import { DenomKeyType, DenominationPubKey, ExchangeAuditor, + ExchangeWithdrawValue, ExchangeRefundRequest, ExchangeWireAccount, PartialPeerContractTerms, @@ -1147,6 +1148,7 @@ export interface TalerErrorDetail { export interface PlanchetUnblindInfo { denomPub: DenominationPubKey; blindingKey: string; + exchangeWithdrawValues: ExchangeWithdrawValue; } export interface WithdrawalPlanchet { @@ -1161,6 +1163,7 @@ export interface WithdrawalPlanchet { coinValue: AmountJson; coinEvHash: string; ageCommitmentProof?: AgeCommitmentProof; + exchangeWithdrawValues: ExchangeWithdrawValue; } export interface PlanchetCreationRequest { @@ -1172,6 +1175,8 @@ export interface PlanchetCreationRequest { reservePub: string; reservePriv: string; restrictAge?: number; + exchangeWithdrawValues?: ExchangeWithdrawValue; + csNonce?: string; } /** @@ -3012,6 +3017,8 @@ export interface RefreshPlanchetInfo { maxAge: number; ageCommitmentProof?: AgeCommitmentProof; + + exchangeWithdrawValues: ExchangeWithdrawValue; } /** diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -40,9 +40,16 @@ import { CoinDepositPermission, CoinEnvelope, createHashContext, + csBlind, + csUnblind, + csVerify, decodeCrock, decryptContractForDeposit, decryptContractForMerge, + DenominationPubKey, + deriveBSeed, + deriveCsCoinPriv, + deriveCsNonce, DenomKeyType, DepositInfo, durationRoundedToBuffer, @@ -58,6 +65,7 @@ import { encryptContractForMerge, ExchangeProtocolVersion, ExchangeSignKeyJson, + ExchangeWithdrawValue, getRandomBytes, GlobalFees, hash, @@ -153,6 +161,66 @@ export function setDebugDumpSigs(b: boolean) { debugDumpSigs = b; } +async function createRefreshCoinEnvelope( + tci: TalerCryptoInterfaceR, + denomPub: DenominationPubKey, + fresh: FreshCoinEncoded, + coinPubHash: Uint8Array, + coinIndex: number, + blindingSeed: string | undefined, + exchangeWithdrawValues: ExchangeWithdrawValue | undefined, +): Promise<{ + coinEv: CoinEnvelope; + blindingKey: string; + exchangeWithdrawValues: ExchangeWithdrawValue; +}> { + if (denomPub.cipher === DenomKeyType.Rsa) { + const blindResult = await tci.rsaBlind(tci, { + bks: fresh.bks, + hm: encodeCrock(coinPubHash), + pub: denomPub.rsa_public_key, + }); + return { + coinEv: { + cipher: DenomKeyType.Rsa, + rsa_blinded_planchet: blindResult.blinded, + }, + blindingKey: fresh.bks, + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, + }; + } + if ( + denomPub.cipher !== DenomKeyType.ClauseSchnorr || + exchangeWithdrawValues?.cipher !== DenomKeyType.ClauseSchnorr || + blindingSeed == null + ) { + throw Error("missing Clause-Schnorr refresh blinding inputs"); + } + const rPubs: [Uint8Array, Uint8Array] = [ + decodeCrock(exchangeWithdrawValues.r_pub_0), + decodeCrock(exchangeWithdrawValues.r_pub_1), + ]; + const bseed = decodeCrock(fresh.bks); + const blindedChallenges = await csBlind( + bseed, + rPubs, + decodeCrock(denomPub.cs_public_key), + coinPubHash, + ); + return { + coinEv: { + cipher: DenomKeyType.ClauseSchnorr, + cs_nonce: encodeCrock( + deriveCsNonce(decodeCrock(blindingSeed), "melt", coinIndex), + ), + cs_blinded_c0: encodeCrock(blindedChallenges[0]), + cs_blinded_c1: encodeCrock(blindedChallenges[1]), + }, + blindingKey: encodeCrock(bseed), + exchangeWithdrawValues, + }; +} + /** * Interface for (asynchronous) cryptographic operations that * Taler uses. @@ -215,6 +283,10 @@ export interface TalerCryptoInterface { req: UnblindDenominationSignatureRequest, ): Promise<UnblindedDenominationSignature>; + verifyDenominationSignature( + req: VerifyDenominationSignatureRequest, + ): Promise<ValidationResult>; + unblindTokenIssueSignature( req: UnblindTokenIssueSignatureRequest, ): Promise<UnblindedDenominationSignature>; @@ -421,6 +493,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<UnblindedDenominationSignature> { throw new Error("Function not implemented."); }, + verifyDenominationSignature: function ( + req: VerifyDenominationSignatureRequest, + ): Promise<ValidationResult> { + throw new Error("Function not implemented."); + }, unblindTokenIssueSignature: function ( req: UnblindTokenIssueSignatureRequest, ): Promise<UnblindedDenominationSignature> { @@ -621,6 +698,7 @@ export interface SetupRefreshPlanchetRequestV2 { */ refreshPlanchetSecret: string; coinNumber: number; + exchangeWithdrawValues?: ExchangeWithdrawValue; } export interface SetupWithdrawalPlanchetRequest { @@ -644,6 +722,8 @@ export interface SignWithdrawalRequest { denomsPubHashes: HashCodeString[]; + blindingSeed?: string; + // FIXME: Age restriction stuff } @@ -798,6 +878,12 @@ export interface UnblindDenominationSignatureRequest { evSig: BlindedDenominationSignature; } +export interface VerifyDenominationSignatureRequest { + coinPubHash: string; + denomPub: DenominationPubKey; + denomSig: UnblindedDenominationSignature; +} + export interface UnblindTokenIssueSignatureRequest { slate: SlateUnblindInfo; evSig: TokenIssueBlindSig; @@ -807,6 +893,7 @@ export interface FreshCoinEncoded { coinPub: string; coinPriv: string; bks: string; + planchetMasterSecret?: string; } export interface RsaUnblindRequest { @@ -921,17 +1008,27 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { ): Promise<FreshCoinEncoded> { const planchetMasterSecret = decodeCrock(req.refreshPlanchetSecret); - const coinPriv = kdfKw({ - ikm: planchetMasterSecret, - outputLength: 32, - salt: stringToBytes("coin"), - }); - - const bks = kdfKw({ - ikm: planchetMasterSecret, - outputLength: 32, - salt: stringToBytes("bks"), - }); + let coinPriv: Uint8Array; + let bks: Uint8Array; + if (req.exchangeWithdrawValues?.cipher === DenomKeyType.ClauseSchnorr) { + const rPubs: [Uint8Array, Uint8Array] = [ + decodeCrock(req.exchangeWithdrawValues.r_pub_0), + decodeCrock(req.exchangeWithdrawValues.r_pub_1), + ]; + coinPriv = deriveCsCoinPriv(planchetMasterSecret, rPubs); + bks = deriveBSeed(planchetMasterSecret, rPubs); + } else { + coinPriv = kdfKw({ + ikm: planchetMasterSecret, + outputLength: 32, + salt: stringToBytes("coin"), + }); + bks = kdfKw({ + ikm: planchetMasterSecret, + outputLength: 32, + salt: stringToBytes("bks"), + }); + } const coinPrivEnc = encodeCrock(coinPriv); const coinPubRes = await tci.eddsaGetPublic(tci, { @@ -942,6 +1039,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { bks: encodeCrock(bks), coinPriv: coinPrivEnc, coinPub: coinPubRes.pub, + planchetMasterSecret: req.refreshPlanchetSecret, }; }, @@ -966,6 +1064,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { bks: encodeCrock(bks), coinPriv: coinPrivEnc, coinPub: coinPubRes.pub, + planchetMasterSecret: encodeCrock(coinPriv), }; }, @@ -974,75 +1073,120 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { req: PlanchetCreationRequest, ): Promise<WithdrawalPlanchet> { const denomPub = req.denomPub; - if (denomPub.cipher === DenomKeyType.Rsa) { - const reservePub = decodeCrock(req.reservePub); - const derivedPlanchet = await tci.setupWithdrawalPlanchet(tci, { - coinNumber: req.coinIndex, - secretSeed: req.secretSeed, - }); - - let maybeAcp: AgeCommitmentProof | undefined = undefined; - let maybeAgeCommitmentHash: string | undefined = undefined; - if (denomPub.age_mask) { - const age = req.restrictAge || AgeRestriction.AGE_UNRESTRICTED; - logger.info(`creating age-restricted planchet (age ${age})`); - maybeAcp = await AgeRestriction.restrictionCommitSeeded( - denomPub.age_mask, - age, - stringToBytes(req.secretSeed), - ); - maybeAgeCommitmentHash = AgeRestriction.hashCommitment( - maybeAcp.commitment, - ); - } + const reservePub = decodeCrock(req.reservePub); + let derivedPlanchet = await tci.setupWithdrawalPlanchet(tci, { + coinNumber: req.coinIndex, + secretSeed: req.secretSeed, + }); - const coinPubHash = hashCoinPub( - derivedPlanchet.coinPub, - maybeAgeCommitmentHash, + let maybeAcp: AgeCommitmentProof | undefined = undefined; + let maybeAgeCommitmentHash: string | undefined = undefined; + if (denomPub.age_mask) { + const age = req.restrictAge || AgeRestriction.AGE_UNRESTRICTED; + logger.info(`creating age-restricted planchet (age ${age})`); + maybeAcp = await AgeRestriction.restrictionCommitSeeded( + denomPub.age_mask, + age, + stringToBytes(req.secretSeed), ); + maybeAgeCommitmentHash = AgeRestriction.hashCommitment( + maybeAcp.commitment, + ); + } + const coinPubHash = hashCoinPub( + derivedPlanchet.coinPub, + maybeAgeCommitmentHash, + ); + + let coinEv: CoinEnvelope; + let blindingKey: string; + let exchangeWithdrawValues: ExchangeWithdrawValue; + if (denomPub.cipher === DenomKeyType.Rsa) { const blindResp = await tci.rsaBlind(tci, { bks: derivedPlanchet.bks, hm: encodeCrock(coinPubHash), pub: denomPub.rsa_public_key, }); - const coinEv: CoinEnvelope = { + coinEv = { cipher: DenomKeyType.Rsa, rsa_blinded_planchet: blindResp.blinded, }; - const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount; - const denomPubHash = hashDenomPub(req.denomPub); - const evHash = hashCoinEv(coinEv, encodeCrock(denomPubHash)); - const withdrawRequest = buildSigPS( - TalerSignaturePurpose.WALLET_RESERVE_WITHDRAW, - ) - .put(bufferFromAmount(amountWithFee)) - .put(denomPubHash) - .put(evHash) - .build(); - - const sigResult = await tci.eddsaSign(tci, { - msg: encodeCrock(withdrawRequest), - priv: req.reservePriv, - }); - - const planchet: WithdrawalPlanchet = { - blindingKey: derivedPlanchet.bks, - coinEv, - coinPriv: derivedPlanchet.coinPriv, - coinPub: derivedPlanchet.coinPub, - coinValue: req.value, - denomPub, - denomPubHash: encodeCrock(denomPubHash), - reservePub: encodeCrock(reservePub), - withdrawSig: sigResult.sig, - coinEvHash: encodeCrock(evHash), - ageCommitmentProof: maybeAcp, + blindingKey = derivedPlanchet.bks; + exchangeWithdrawValues = { cipher: DenomKeyType.Rsa }; + } else if (denomPub.cipher === DenomKeyType.ClauseSchnorr) { + if ( + req.exchangeWithdrawValues?.cipher !== DenomKeyType.ClauseSchnorr || + req.csNonce == null + ) { + throw Error("missing Clause-Schnorr blinding inputs"); + } + const rPubs: [Uint8Array, Uint8Array] = [ + decodeCrock(req.exchangeWithdrawValues.r_pub_0), + decodeCrock(req.exchangeWithdrawValues.r_pub_1), + ]; + const planchetMasterSecret = decodeCrock( + derivedPlanchet.planchetMasterSecret ?? derivedPlanchet.coinPriv, + ); + const coinPriv = deriveCsCoinPriv(planchetMasterSecret, rPubs); + const coinPrivEnc = encodeCrock(coinPriv); + const coinPub = (await tci.eddsaGetPublic(tci, { priv: coinPrivEnc })) + .pub; + const bseed = deriveBSeed(planchetMasterSecret, rPubs); + derivedPlanchet = { + bks: encodeCrock(bseed), + coinPriv: coinPrivEnc, + coinPub, + planchetMasterSecret: encodeCrock(planchetMasterSecret), + }; + const csCoinPubHash = hashCoinPub(coinPub, maybeAgeCommitmentHash); + const blindedChallenges = await csBlind( + bseed, + rPubs, + decodeCrock(denomPub.cs_public_key), + csCoinPubHash, + ); + coinEv = { + cipher: DenomKeyType.ClauseSchnorr, + cs_nonce: req.csNonce, + cs_blinded_c0: encodeCrock(blindedChallenges[0]), + cs_blinded_c1: encodeCrock(blindedChallenges[1]), }; - return planchet; + blindingKey = encodeCrock(bseed); + exchangeWithdrawValues = req.exchangeWithdrawValues; } else { throw Error("unsupported cipher, unable to create planchet"); } + + const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount; + const denomPubHash = hashDenomPub(req.denomPub); + const evHash = hashCoinEv(coinEv, encodeCrock(denomPubHash)); + const withdrawRequest = buildSigPS( + TalerSignaturePurpose.WALLET_RESERVE_WITHDRAW, + ) + .put(bufferFromAmount(amountWithFee)) + .put(denomPubHash) + .put(evHash) + .build(); + const sigResult = await tci.eddsaSign(tci, { + msg: encodeCrock(withdrawRequest), + priv: req.reservePriv, + }); + + return { + blindingKey, + coinEv, + coinPriv: derivedPlanchet.coinPriv, + coinPub: derivedPlanchet.coinPub, + coinValue: req.value, + denomPub, + denomPubHash: encodeCrock(denomPubHash), + reservePub: encodeCrock(reservePub), + withdrawSig: sigResult.sig, + coinEvHash: encodeCrock(evHash), + ageCommitmentProof: maybeAcp, + exchangeWithdrawValues, + }; }, async createSlate( @@ -1405,9 +1549,68 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { cipher: DenomKeyType.Rsa, rsa_signature: encodeCrock(denomSig), }; - } else { - throw Error(`unblinding for cipher ${req.evSig.cipher} not implemented`); + } else if (req.evSig.cipher === DenomKeyType.ClauseSchnorr) { + if ( + req.planchet.denomPub.cipher !== DenomKeyType.ClauseSchnorr || + req.planchet.exchangeWithdrawValues.cipher !== + DenomKeyType.ClauseSchnorr + ) { + throw new Error( + "planchet cipher does not match blind signature cipher", + ); + } + const ewv = req.planchet.exchangeWithdrawValues; + const sig = await csUnblind( + decodeCrock(req.planchet.blindingKey), + [decodeCrock(ewv.r_pub_0), decodeCrock(ewv.r_pub_1)], + decodeCrock(req.planchet.denomPub.cs_public_key), + req.evSig.b, + { + sBlind: decodeCrock(req.evSig.s), + rPubBlind: decodeCrock(req.evSig.b === 0 ? ewv.r_pub_0 : ewv.r_pub_1), + }, + ); + return { + cipher: DenomKeyType.ClauseSchnorr, + cs_signature_r: encodeCrock(sig.rPub), + cs_signature_s: encodeCrock(sig.s), + }; + } + throw Error("unsupported denomination signature cipher"); + }, + + async verifyDenominationSignature( + tci: TalerCryptoInterfaceR, + req: VerifyDenominationSignatureRequest, + ): Promise<ValidationResult> { + if ( + req.denomPub.cipher === DenomKeyType.Rsa && + req.denomSig.cipher === DenomKeyType.Rsa + ) { + return { + valid: rsaVerify( + decodeCrock(req.coinPubHash), + decodeCrock(req.denomSig.rsa_signature), + decodeCrock(req.denomPub.rsa_public_key), + ), + }; + } + if ( + req.denomPub.cipher === DenomKeyType.ClauseSchnorr && + req.denomSig.cipher === DenomKeyType.ClauseSchnorr + ) { + return { + valid: await csVerify( + decodeCrock(req.coinPubHash), + { + rPub: decodeCrock(req.denomSig.cs_signature_r), + s: decodeCrock(req.denomSig.cs_signature_s), + }, + decodeCrock(req.denomPub.cs_public_key), + ), + }; } + return { valid: false }; }, async unblindTokenIssueSignature( @@ -1513,24 +1716,19 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { const walletDataHash = depositInfo.walletDataHash ? decodeCrock(depositInfo.walletDataHash) : new Uint8Array(64); - let d: Uint8Array; - if (depositInfo.denomKeyType === DenomKeyType.Rsa) { - d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT) - .put(decodeCrock(depositInfo.contractTermsHash)) - .put(hAgeCommitment) - .put(hExt) - .put(decodeCrock(depositInfo.wireInfoHash)) - .put(decodeCrock(depositInfo.denomPubHash)) - .put(timestampRoundedToBuffer(depositInfo.timestamp)) - .put(timestampRoundedToBuffer(depositInfo.refundDeadline)) - .put(bufferFromAmount(depositInfo.spendAmount)) - .put(bufferFromAmount(depositInfo.feeDeposit)) - .put(decodeCrock(depositInfo.merchantPub)) - .put(walletDataHash) - .build(); - } else { - throw Error("unsupported exchange protocol version"); - } + const d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT) + .put(decodeCrock(depositInfo.contractTermsHash)) + .put(hAgeCommitment) + .put(hExt) + .put(decodeCrock(depositInfo.wireInfoHash)) + .put(decodeCrock(depositInfo.denomPubHash)) + .put(timestampRoundedToBuffer(depositInfo.timestamp)) + .put(timestampRoundedToBuffer(depositInfo.refundDeadline)) + .put(bufferFromAmount(depositInfo.spendAmount)) + .put(bufferFromAmount(depositInfo.feeDeposit)) + .put(decodeCrock(depositInfo.merchantPub)) + .put(walletDataHash) + .build(); const coinSigRes = await this.eddsaSign(tci, { msg: encodeCrock(d), priv: depositInfo.coinPriv, @@ -1543,34 +1741,24 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { logger.info(`Deposit signature: ${toHexString(d)}`); } - if (depositInfo.denomKeyType === DenomKeyType.Rsa) { - const s: CoinDepositPermission = { - coin_pub: depositInfo.coinPub, - coin_sig: coinSigRes.sig, - contribution: Amounts.stringify(depositInfo.spendAmount), - h_denom: depositInfo.denomPubHash, - exchange_url: depositInfo.exchangeBaseUrl, - ub_sig: { - cipher: DenomKeyType.Rsa, - rsa_signature: depositInfo.denomSig.rsa_signature, - }, - }; - - if (depositInfo.requiredMinimumAge) { - // These are only required by the merchant - s.minimum_age_sig = minimumAgeSig; - s.age_commitment = - depositInfo.ageCommitmentProof?.commitment.publicKeys; - } else if (depositInfo.ageCommitmentProof) { - s.h_age_commitment = encodeCrock(hAgeCommitment); - } + const s: CoinDepositPermission = { + coin_pub: depositInfo.coinPub, + coin_sig: coinSigRes.sig, + contribution: Amounts.stringify(depositInfo.spendAmount), + h_denom: depositInfo.denomPubHash, + exchange_url: depositInfo.exchangeBaseUrl, + ub_sig: depositInfo.denomSig, + }; - return s; - } else { - throw Error( - `unsupported denomination cipher (${depositInfo.denomKeyType})`, - ); + if (depositInfo.requiredMinimumAge) { + // These are only required by the merchant + s.minimum_age_sig = minimumAgeSig; + s.age_commitment = depositInfo.ageCommitmentProof?.commitment.publicKeys; + } else if (depositInfo.ageCommitmentProof) { + s.h_age_commitment = encodeCrock(hAgeCommitment); } + + return s; }, async deriveRefreshSessionV2( @@ -1626,8 +1814,9 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { const sessionHc = createHashContext(); sessionHc.update(decodeCrock(sessionPublicSeed)); - const blindingSeed = new Uint8Array(32); - // For CS, we'd need to also read th real blinding_seed into sessionHc. + const blindingSeed = req.blindingSeed + ? decodeCrock(req.blindingSeed) + : new Uint8Array(32); sessionHc.update(blindingSeed); sessionHc.update(decodeCrock(meltCoinPub)); sessionHc.update(bufferFromAmount(valueWithFee)); @@ -1679,6 +1868,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { let fresh: FreshCoinEncoded = await tci.setupRefreshPlanchetV2(tci, { coinNumber: coinIndex, refreshPlanchetSecret: encodeCrock(myPlanchetSecret), + exchangeWithdrawValues: req.exchangeWithdrawValues?.[coinIndex], }); let newAc: AgeCommitmentProof | undefined = undefined; let newAch: HashCodeString | undefined = undefined; @@ -1695,24 +1885,23 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { 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 envelope = await createRefreshCoinEnvelope( + tci, + denomSel.denomPub, + fresh, + coinPubHash, + coinIndex, + req.blindingSeed, + req.exchangeWithdrawValues?.[coinIndex], + ); + const { coinEv } = envelope; const coinEvHash = hashCoinEv( coinEv, encodeCrock(hashDenomPub(denomSel.denomPub)), ); const planchet: RefreshPlanchetInfo = { - blindingKey: fresh.bks, + blindingKey: envelope.blindingKey, + exchangeWithdrawValues: envelope.exchangeWithdrawValues, coinEv, coinPriv: fresh.coinPriv, coinPub: fresh.coinPub, @@ -1841,8 +2030,9 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { 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. + const blindingSeed = req.blindingSeed + ? decodeCrock(req.blindingSeed) + : new Uint8Array(32); sessionHc.update(blindingSeed); sessionHc.update(decodeCrock(meltCoinPub)); sessionHc.update(bufferFromAmount(valueWithFee)); @@ -1889,6 +2079,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { { coinNumber: coinIndex, refreshPlanchetSecret: encodeCrock(myPlanchetSecret), + exchangeWithdrawValues: req.exchangeWithdrawValues?.[coinIndex], }, ); let newAc: AgeCommitmentProof | undefined = undefined; @@ -1906,24 +2097,23 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { 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 envelope = await createRefreshCoinEnvelope( + tci, + denomSel.denomPub, + fresh, + coinPubHash, + coinIndex, + req.blindingSeed, + req.exchangeWithdrawValues?.[coinIndex], + ); + const { coinEv } = envelope; const coinEvHash = hashCoinEv( coinEv, encodeCrock(hashDenomPub(denomSel.denomPub)), ); const planchet: RefreshPlanchetInfo = { - blindingKey: fresh.bks, + blindingKey: envelope.blindingKey, + exchangeWithdrawValues: envelope.exchangeWithdrawValues, coinEv, coinPriv: fresh.coinPriv, coinPub: fresh.coinPub, @@ -2084,6 +2274,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { ); const planchet: RefreshPlanchetInfo = { blindingKey: encodeCrock(blindingFactor), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, coinEv, coinPriv: encodeCrock(coinPriv), coinPub: encodeCrock(coinPub), @@ -2579,9 +2770,10 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { const hPlanchets = hc.finish(); - // Clause Schnorr blinding seed - // Wallet doesn't fully support CS yet. - const blindingSeed = new Uint8Array(32); + // RSA withdrawals commit to an all-zero blinding seed. + const blindingSeed = req.blindingSeed + ? decodeCrock(req.blindingSeed) + : new Uint8Array(32); const withdrawRequest = buildSigPS( TalerSignaturePurpose.WALLET_RESERVE_WITHDRAW, diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -36,6 +36,7 @@ import { EddsaPublicKeyString, EddsaSignatureString, ExchangeProtocolVersion, + ExchangeWithdrawValue, HashCodeString, RefreshPlanchetInfo, TalerProtocolTimestamp, @@ -85,6 +86,8 @@ export interface DeriveRefreshSessionRequestV2 { meltCoinAgeCommitmentProof?: AgeCommitmentProof; newCoinDenoms: RefreshNewDenomInfo[]; feeRefresh: AmountJson; + blindingSeed?: string; + exchangeWithdrawValues?: (ExchangeWithdrawValue | undefined)[]; } /** @@ -104,6 +107,8 @@ export interface DeriveRefreshSessionRequestV3 { meltCoinAgeCommitmentProof?: AgeCommitmentProof; newCoinDenoms: RefreshNewDenomInfo[]; feeRefresh: AmountJson; + blindingSeed?: string; + exchangeWithdrawValues?: (ExchangeWithdrawValue | undefined)[]; } export interface DerivedRefreshSession { diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts @@ -43,6 +43,7 @@ import { SignedTokenEnvelope, CurrencySpecification, ExchangeAuditor, + ExchangeWithdrawValue, ExchangeGlobalFees, WireInfo, AccountLimit, @@ -805,6 +806,8 @@ export interface WalletPlanchet { coinEvHash: string; ageCommitmentProof?: AgeCommitmentProof; + + exchangeWithdrawValues: ExchangeWithdrawValue; } export interface WalletDonationSummary { @@ -1562,6 +1565,8 @@ export interface WalletCoin { */ blindingKey: string; + exchangeWithdrawValues: ExchangeWithdrawValue; + /** * Hash of the coin envelope. * diff --git a/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts b/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts @@ -17,10 +17,8 @@ /** * Tests for the sqlite schema migration mechanism. * - * schemaMigrations is empty, so nothing in the wallet exercises this path: - * the first real migration would otherwise be both the first use of the - * mechanism and a change to a user's database at the same time. These tests - * drive it with synthetic migrations instead. + * These tests supplement the wallet's real migrations with synthetic ones in + * order to exercise DDL backfills, rollback and validation behavior. */ import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; @@ -30,7 +28,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { SchemaMigration } from "./db-sqlite-schema.js"; +import { + SchemaMigration, + schemaMigrations, +} from "./db-sqlite-schema.js"; import { initSqliteWalletDb } from "./dbtx-sqlite.js"; /** @@ -94,8 +95,8 @@ test("migration applies DDL and backfills existing rows", async () => { ); assert.deepStrictEqual( applied.map((r) => Number(r.version)), - [1, 2], - "the baseline and the migration must both be recorded", + [1, 2, ...schemaMigrations.map((m) => m.version)].sort((a, b) => a - b), + "the baseline, synthetic migration and wallet migration must be recorded", ); assert.strictEqual(applied[1].name, "add-tombstone-note"); // Microseconds, per the schema's convention for INTEGER timestamps. A @@ -199,7 +200,7 @@ test("a failing migration rolls back and is not recorded", async () => { ); assert.deepStrictEqual( applied.map((r) => Number(r.version)), - [1], + [1, ...schemaMigrations.map((m) => m.version)].sort((a, b) => a - b), "a failed migration must not be recorded as applied", ); await db.close(); 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 = 5; +export const SQLITE_SCHEMA_VERSION = 6; /** * Tables of the IndexedDB emulation, children before parents. @@ -1308,4 +1308,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS refund_items_by_coin_and_rtxid * {@link SQLITE_SCHEMA_VERSION} -- and goes in one place only, since a fresh * database runs the baseline *and* the migrations. */ -export const schemaMigrations: SchemaMigration[] = []; +export const schemaMigrations: SchemaMigration[] = [ + { + version: 6, + name: "clause-schnorr-exchange-withdraw-values", + statements: [ + 'ALTER TABLE planchets ADD COLUMN exchange_withdraw_values TEXT NOT NULL DEFAULT \'{"cipher":"RSA"}\'', + 'ALTER TABLE coins ADD COLUMN exchange_withdraw_values TEXT NOT NULL DEFAULT \'{"cipher":"RSA"}\'', + ], + }, +]; diff --git a/packages/taler-wallet-core/src/dbtx-bench.ts b/packages/taler-wallet-core/src/dbtx-bench.ts @@ -198,6 +198,7 @@ async function populate( denomPubHash: hash(`denom-${d}`), denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: `sig-${i}` }, blindingKey: key(`bk-${i}`), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, coinEvHash: hash(`evh-${i}`), // Derived from the row *within* a denomination, not from i: with // `i % 4` the dormant coins land on multiples of 4, which for many diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -217,6 +217,7 @@ function makeCoin(coinPub: string): WalletCoin { rsa_signature: "sig-blob", }, blindingKey: ck("bk-1"), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, coinEvHash: ckh(`evh-${coinPub}`), status: CoinStatus.Fresh, maxAge: 0, @@ -399,6 +400,7 @@ function makePlanchet( lastError: undefined, denomPubHash: ckh("dph-pl"), blindingKey: ck("bk-pl"), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, withdrawSig: ckh("sig-pl"), coinEv: { cipher: DenomKeyType.Rsa, diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -1133,6 +1133,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { denomPubHash: dbToCrock(row.denom_pub_hash), denomSig: dbToJson(row.denom_sig), blindingKey: dbToCrock(row.blinding_key), + exchangeWithdrawValues: dbToJson(row.exchange_withdraw_values), coinEvHash: dbToCrock(row.coin_ev_hash), status: str(row.status) as CoinStatus, maxAge: num(row.max_age), @@ -1159,10 +1160,10 @@ export class SqliteWalletTransaction implements WalletDbTransaction { `INSERT INTO coins ( coin_pub, coin_priv, exchange_base_url, exchange_master_pub, denom_pub_hash, denom_sig, - blinding_key, coin_ev_hash, status, visible, max_age, + blinding_key, exchange_withdraw_values, coin_ev_hash, status, visible, max_age, age_commitment_proof, coin_source, source_transaction_id ) VALUES ( - $pub, $priv, $url, $emp, $dph, $sig, $bk, $ceh, $status, $visible, + $pub, $priv, $url, $emp, $dph, $sig, $bk, $ewv, $ceh, $status, $visible, $age, $acp, $source, $stid ) ON CONFLICT(coin_pub) DO UPDATE SET @@ -1172,6 +1173,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { denom_pub_hash = excluded.denom_pub_hash, denom_sig = excluded.denom_sig, blinding_key = excluded.blinding_key, + exchange_withdraw_values = excluded.exchange_withdraw_values, coin_ev_hash = excluded.coin_ev_hash, status = excluded.status, visible = excluded.visible, @@ -1187,6 +1189,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { dph: crockToDb(coin.denomPubHash), sig: jsonToDb(coin.denomSig), bk: crockToDb(coin.blindingKey), + ewv: jsonToDb(coin.exchangeWithdrawValues), ceh: crockToDb(coin.coinEvHash), status: coin.status, visible: coin.visible ?? null, @@ -2403,6 +2406,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { lastError: dbToOptJson(row.last_error), denomPubHash: dbToCrock(row.denom_pub_hash), blindingKey: dbToCrock(row.blinding_key), + exchangeWithdrawValues: dbToJson(row.exchange_withdraw_values), withdrawSig: dbToCrock(row.withdraw_sig), coinEv: dbToJson(row.coin_ev), coinEvHash: dbToCrock(row.coin_ev_hash), @@ -2424,10 +2428,10 @@ export class SqliteWalletTransaction implements WalletDbTransaction { await this.run( `INSERT INTO planchets ( coin_pub, coin_priv, withdrawal_group_id, coin_idx, planchet_status, - last_error, denom_pub_hash, blinding_key, withdraw_sig, coin_ev, + last_error, denom_pub_hash, blinding_key, exchange_withdraw_values, withdraw_sig, coin_ev, coin_ev_hash, age_commitment_proof ) VALUES ( - $pub, $priv, $wgid, $idx, $status, $err, $dph, $bk, $sig, $ev, + $pub, $priv, $wgid, $idx, $status, $err, $dph, $bk, $ewv, $sig, $ev, $evh, $acp ) ON CONFLICT(coin_pub) DO UPDATE SET @@ -2438,6 +2442,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { last_error = excluded.last_error, denom_pub_hash = excluded.denom_pub_hash, blinding_key = excluded.blinding_key, + exchange_withdraw_values = excluded.exchange_withdraw_values, withdraw_sig = excluded.withdraw_sig, coin_ev = excluded.coin_ev, coin_ev_hash = excluded.coin_ev_hash, @@ -2451,6 +2456,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { err: rec.lastError === undefined ? null : jsonToDb(rec.lastError), dph: crockToDb(rec.denomPubHash), bk: crockToDb(rec.blindingKey), + ewv: jsonToDb(rec.exchangeWithdrawValues), sig: crockToDb(rec.withdrawSig), ev: jsonToDb(rec.coinEv), evh: crockToDb(rec.coinEvHash), diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -2177,8 +2177,40 @@ export async function updateExchangeFromUrlHandler( switch (denomFamily.cipher) { case "CS": case "CS+age_restricted": { - logger.warn("Clause-Schnorr denominations not supported"); - continue; + const ageMask = + denomFamily.cipher === "CS+age_restricted" ? denomFamily.age_mask : 0; + if (ageMask !== 0) { + exchangeAgeMask = ageMask; + } + for (const denom of denomFamily.denoms) { + const denomPub: DenominationPubKey = { + age_mask: ageMask, + cipher: DenomKeyType.ClauseSchnorr, + cs_public_key: denom.cs_pub, + }; + const denomPubHash = encodeCrock(hashDenomPub(denomPub)); + const di: DenominationInfo = { + denomPub, + denomPubHash, + exchangeBaseUrl, + exchangeMasterPub: keysInfo.master_public_key, + feeDeposit: denomFamily.fee_deposit, + feeRefresh: denomFamily.fee_refresh, + feeRefund: denomFamily.fee_refund, + feeWithdraw: denomFamily.fee_withdraw, + stampExpireDeposit: denom.stamp_expire_deposit, + stampExpireLegal: denom.stamp_expire_legal, + stampExpireWithdraw: denom.stamp_expire_withdraw, + stampStart: denom.stamp_start, + value: denomFamily.value, + isOffered: true, + isLost: denom.lost ?? false, + masterSig: denom.master_sig, + }; + denomInfos.push(di); + currentDenomSet.add(denomPubHash); + } + break; } case "RSA": case "RSA+age_restricted": { diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -3442,6 +3442,8 @@ export async function validateAndStoreToken( if (!(tokenIssuePub.cipher === DenomKeyType.Rsa)) throw Error("unsupported cipher"); + if (tokenIssueSig.cipher !== DenomKeyType.Rsa) + throw Error("unsupported cipher"); const rsaVerifyResp = await wex.cryptoApi.rsaVerify({ hm: tokenUsePub, diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -35,6 +35,8 @@ import { CoinStatus, DenominationInfo, DenomKeyType, + decodeCrock, + deriveCsRefreshBlindingSeed, Duration, EddsaPublicKeyString, EddsaSignatureString, @@ -42,6 +44,7 @@ import { ExchangeMeltRequestV2, ExchangeRefreshRevealRequestV2, ExchangeRefundRequest, + ExchangeWithdrawValue, FlightRecordEvent, fnutil, ForceRefreshRequest, @@ -49,6 +52,7 @@ import { getErrorDetailFromException, getRandomBytes, HashCodeString, + hashCoinPub, HttpStatusCode, j2s, LibtoolVersion, @@ -57,6 +61,7 @@ import { NotificationType, RefreshPlanchetInfo, RefreshReason, + succeedOrThrow, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, @@ -630,6 +635,7 @@ interface DerivedRefreshCommon { /** The refresh commitment, i.e. the session hash. */ hash: string; meltValueWithFee: AmountJson; + blindingSeed?: string; } type DerivedRefresh = @@ -661,6 +667,49 @@ async function deriveRefreshSession( newCoinDenoms: RefreshNewDenomInfo[], ): Promise<DerivedRefresh> { checkLogicInvariant(refreshSession.sessionPublicSeed != null); + const nks: { coin_offset: number; denom_pub_hash: string }[] = []; + let coinOffset = 0; + for (const nd of newCoinDenoms) { + for (let i = 0; i < nd.count; i++, coinOffset++) { + if (nd.denomPub.cipher === DenomKeyType.ClauseSchnorr) { + nks.push({ coin_offset: coinOffset, denom_pub_hash: nd.denomPubHash }); + } + } + } + let blindingSeed: string | undefined; + let exchangeWithdrawValues: (ExchangeWithdrawValue | undefined)[] | undefined; + if (nks.length > 0) { + blindingSeed = encodeCrock( + deriveCsRefreshBlindingSeed( + decodeCrock(refreshSession.sessionPublicSeed), + decodeCrock(oldCoin.coinPriv), + ), + ); + const prep = succeedOrThrow( + await walletExchangeClient( + oldCoin.exchangeBaseUrl, + wex, + ).postBlindingPrepare({ + body: { + cipher: DenomKeyType.ClauseSchnorr, + operation: "melt", + seed: blindingSeed, + nks, + }, + }), + ); + if (prep.r_pubs.length !== nks.length) { + throw Error("exchange returned wrong number of Clause-Schnorr R-pairs"); + } + exchangeWithdrawValues = new Array(coinOffset); + for (let i = 0; i < nks.length; i++) { + exchangeWithdrawValues[nks[i].coin_offset] = { + cipher: DenomKeyType.ClauseSchnorr, + r_pub_0: prep.r_pubs[i][0], + r_pub_1: prep.r_pubs[i][1], + }; + } + } const req = { kappa: 3, meltCoinDenomPubHash: oldCoin.denomPubHash, @@ -671,6 +720,8 @@ async function deriveRefreshSession( meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, newCoinDenoms, sessionPublicSeed: refreshSession.sessionPublicSeed, + blindingSeed, + exchangeWithdrawValues, }; const protocolVersion = refreshProtocolVersionOf(refreshSession); switch (protocolVersion) { @@ -684,6 +735,7 @@ async function deriveRefreshSession( meltValueWithFee: derived.meltValueWithFee, batchSeeds: derived.batchSeeds, transferPubs: derived.transferPubs, + blindingSeed, }; } case REFRESH_PROTOCOL_V27: { @@ -695,6 +747,7 @@ async function deriveRefreshSession( hash: derived.hash, meltValueWithFee: derived.meltValueWithFee, signatures: derived.signatures, + blindingSeed, }; } default: @@ -843,6 +896,7 @@ async function refreshMelt( old_denom_sig: oldCoin.denomSig, old_age_commitment_h: maybeAch, refresh_seed: refreshSession.sessionPublicSeed, + blinding_seed: derived.blindingSeed, 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 @@ -1247,19 +1301,32 @@ async function refreshReveal( for (let j = 0; j < refreshSession.newDenoms[i].count; j++) { const newCoinIndex = coins.length; const pc = planchets[norevealIndex][newCoinIndex]; - if (ncd.denomPub.cipher !== DenomKeyType.Rsa) { - throw Error("cipher unsupported"); - } const evSig = resEvSigs[newCoinIndex]; const denomSig = await wex.cryptoApi.unblindDenominationSignature({ planchet: { blindingKey: pc.blindingKey, denomPub: ncd.denomPub, + exchangeWithdrawValues: pc.exchangeWithdrawValues, }, evSig, }); + const coinPubHash = hashCoinPub( + pc.coinPub, + pc.ageCommitmentProof + ? AgeRestriction.hashCommitment(pc.ageCommitmentProof.commitment) + : undefined, + ); + const verification = await wex.cryptoApi.verifyDenominationSignature({ + coinPubHash: encodeCrock(coinPubHash), + denomPub: ncd.denomPub, + denomSig, + }); + if (!verification.valid) { + throw Error("invalid denomination signature on refreshed coin"); + } const coin: WalletCoin = { blindingKey: pc.blindingKey, + exchangeWithdrawValues: pc.exchangeWithdrawValues, coinPriv: pc.coinPriv, coinPub: pc.coinPub, denomPubHash: ncd.denomPubHash, diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -97,9 +97,13 @@ import { checkProtocolInvariant, codecForExchangeLegacyWithdrawBatchResponse, codecForLegitimizationNeededResponse, + decodeCrock, + deriveCsNonce, + deriveCsWithdrawBlindingSeed, encodeCrock, getErrorDetailFromException, getRandomBytes, + hashCoinPub, j2s, makeErrorDetail, succeedOrThrow, @@ -1426,6 +1430,35 @@ async function processPlanchetGenerate( // We handle this gracefully, to fix previous bugs that made it into production. return { badDenom: true }; } + let exchangeWithdrawValues; + let csNonce: string | undefined; + if (denom.denomPub.cipher === DenomKeyType.ClauseSchnorr) { + const blindingSeed = deriveCsWithdrawBlindingSeed( + decodeCrock(withdrawalGroup.secretSeed), + coinIdx, + ); + const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex); + const prep = succeedOrThrow( + await exchangeClient.postBlindingPrepare({ + body: { + cipher: DenomKeyType.ClauseSchnorr, + operation: "withdraw", + seed: encodeCrock(blindingSeed), + nks: [{ coin_offset: 0, denom_pub_hash: denomPubHash }], + }, + }), + ); + checkProtocolInvariant( + prep.r_pubs.length === 1, + "exchange returned wrong number of Clause-Schnorr R-pairs", + ); + exchangeWithdrawValues = { + cipher: DenomKeyType.ClauseSchnorr as const, + r_pub_0: prep.r_pubs[0][0], + r_pub_1: prep.r_pubs[0][1], + }; + csNonce = encodeCrock(deriveCsNonce(blindingSeed, "withdraw", 0)); + } const r = await wex.cryptoApi.createPlanchet({ denomPub: denom.denomPub, feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), @@ -1435,6 +1468,8 @@ async function processPlanchetGenerate( coinIndex: coinIdx, secretSeed: withdrawalGroup.secretSeed, restrictAge: withdrawalGroup.restrictAge, + exchangeWithdrawValues, + csNonce, }); const newPlanchet: WalletPlanchet = { blindingKey: r.blindingKey, @@ -1448,6 +1483,7 @@ async function processPlanchetGenerate( withdrawSig: r.withdrawSig, withdrawalGroupId: withdrawalGroup.withdrawalGroupId, ageCommitmentProof: r.ageCommitmentProof, + exchangeWithdrawValues: r.exchangeWithdrawValues, lastError: undefined, }; await wex.runWalletDbTx(async (tx) => { @@ -1697,6 +1733,7 @@ async function processPlanchetExchangeBatchRequest( const requestCoinIdxs: number[] = []; const coinEvs: CoinEnvelope[] = []; const denomHashes: HashCode[] = []; + let csCoinIdx: number | undefined; checkDbInvariant( !!withdrawalGroup.instructedAmount, "missing instructed amount in withdrawal group", @@ -1738,6 +1775,14 @@ async function processPlanchetExchangeBatchRequest( requestCoinIdxs.push(coinIdx); coinEvs.push(planchet.coinEv); denomHashes.push(planchet.denomPubHash); + if (planchet.coinEv.cipher === DenomKeyType.ClauseSchnorr) { + if (csCoinIdx !== undefined || coinEvs.length !== 1) { + throw Error("Clause-Schnorr withdrawals must use one-coin batches"); + } + csCoinIdx = coinIdx; + } else if (csCoinIdx !== undefined) { + throw Error("Clause-Schnorr withdrawals must use one-coin batches"); + } } }); @@ -1775,6 +1820,15 @@ async function processPlanchetExchangeBatchRequest( coinEvs: coinEvs, denomsPubHashes: denomHashes, reservePriv: withdrawalGroup.reservePriv, + blindingSeed: + csCoinIdx === undefined + ? undefined + : encodeCrock( + deriveCsWithdrawBlindingSeed( + decodeCrock(withdrawalGroup.secretSeed), + csCoinIdx, + ), + ), }); const batchReq: ExchangeWithdrawRequest = { @@ -1783,6 +1837,15 @@ async function processPlanchetExchangeBatchRequest( coin_evs: coinEvs, denoms_h: denomHashes, reserve_sig: sigResp.sig, + blinding_seed: + csCoinIdx === undefined + ? undefined + : encodeCrock( + deriveCsWithdrawBlindingSeed( + decodeCrock(withdrawalGroup.secretSeed), + csCoinIdx, + ), + ), }; const exchangeClient = walletExchangeClient( @@ -1893,28 +1956,27 @@ async function processPlanchetVerifyAndStoreCoin( const { planchet, denomInfo } = d; const planchetDenomPub = denomInfo.denomPub; - if (planchetDenomPub.cipher !== DenomKeyType.Rsa) { - throw Error(`cipher (${planchetDenomPub.cipher}) not supported`); - } - - const evSig = resp; - if (!(evSig.cipher === DenomKeyType.Rsa)) { - throw Error("unsupported cipher"); - } - - const denomSigRsa = await wex.cryptoApi.rsaUnblind({ - bk: planchet.blindingKey, - blindedSig: evSig.blinded_rsa_signature, - pk: planchetDenomPub.rsa_public_key, + const denomSig = await wex.cryptoApi.unblindDenominationSignature({ + planchet: { + blindingKey: planchet.blindingKey, + denomPub: planchetDenomPub, + exchangeWithdrawValues: planchet.exchangeWithdrawValues, + }, + evSig: resp, }); - - const rsaVerifyResp = await wex.cryptoApi.rsaVerify({ - hm: planchet.coinPub, - pk: planchetDenomPub.rsa_public_key, - sig: denomSigRsa.sig, + const coinPubHash = hashCoinPub( + planchet.coinPub, + planchet.ageCommitmentProof + ? AgeRestriction.hashCommitment(planchet.ageCommitmentProof.commitment) + : undefined, + ); + const verifyResp = await wex.cryptoApi.verifyDenominationSignature({ + coinPubHash: encodeCrock(coinPubHash), + denomPub: planchetDenomPub, + denomSig, }); - if (!rsaVerifyResp.valid) { + if (!verifyResp.valid) { await wex.runWalletDbTx(async (tx) => { const planchet = await tx.getPlanchetByGroupAndIndex( withdrawalGroup.withdrawalGroupId, @@ -1933,18 +1995,9 @@ async function processPlanchetVerifyAndStoreCoin( return; } - let denomSig: UnblindedDenominationSignature; - if (planchetDenomPub.cipher === DenomKeyType.Rsa) { - denomSig = { - cipher: planchetDenomPub.cipher, - rsa_signature: denomSigRsa.sig, - }; - } else { - throw Error("unsupported cipher"); - } - const coin: WalletCoin = { blindingKey: planchet.blindingKey, + exchangeWithdrawValues: planchet.exchangeWithdrawValues, coinPriv: planchet.coinPriv, coinPub: planchet.coinPub, denomPubHash: planchet.denomPubHash, @@ -2619,18 +2672,41 @@ async function processWithdrawalGroupPendingReady( logger.trace(`withdrawing ${numTotalCoins} coins`); - for (let i = 0; i < numTotalCoins; i += maxBatchSize) { + const generatedPlanchets = await wex.runWalletDbTx(async (tx) => + tx.getPlanchetsByGroup(withdrawalGroupId), + ); + const planchetByIndex = new Map( + generatedPlanchets.map((p) => [p.coinIdx, p]), + ); + + for (let i = 0; i < numTotalCoins; ) { let resp: WithdrawalBatchResult; if (exchangeVer.current >= 26) { + let batchSize = maxBatchSize; + const firstPlanchet = planchetByIndex.get(i); + if (firstPlanchet?.coinEv.cipher === DenomKeyType.ClauseSchnorr) { + batchSize = 1; + } else { + for (let j = i + 1; j < i + maxBatchSize && j < numTotalCoins; j++) { + if ( + planchetByIndex.get(j)?.coinEv.cipher === DenomKeyType.ClauseSchnorr + ) { + batchSize = j - i; + break; + } + } + } resp = await processPlanchetExchangeBatchRequest(wex, wgContext, { - batchSize: maxBatchSize, + batchSize, coinStartIndex: i, }); + i += batchSize; } else { resp = await processPlanchetExchangeLegacyBatchRequest(wex, wgContext, { batchSize: maxBatchSize, coinStartIndex: i, }); + i += maxBatchSize; } let work: Promise<void>[] = [];