taler-typescript-core

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

commit 41abea61fdd060024e7871bc4a851062b6791554
parent 509412474d1a296afa83e54fd10d6af7c2630ebc
Author: Florian Dold <florian@dold.me>
Date:   Tue, 22 Apr 2025 16:08:48 +0200

wallet-core: scaffolding for new refresh protocol

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-refresh.ts | 2+-
Mpackages/taler-util/src/types-taler-exchange.ts | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/taler-util/src/types-taler-merchant.ts | 4++--
Mpackages/taler-util/src/types-taler-wallet.ts | 8++++----
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 90+++++++++++++++++++++++++------------------------------------------------------
Mpackages/taler-wallet-core/src/crypto/cryptoTypes.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mpackages/taler-wallet-core/src/db.ts | 42++++++++++++++++++++++++++----------------
Mpackages/taler-wallet-core/src/dbless.ts | 4++--
Mpackages/taler-wallet-core/src/refresh.ts | 544+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mpackages/taler-wallet-core/src/withdraw.ts | 12+++++++++---
10 files changed, 587 insertions(+), 288 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh.ts @@ -41,7 +41,7 @@ import { } from "../harness/harness.js"; /** - * Run test for refreshe after a payment. + * Run test for refresh after a payment. */ export async function runWalletRefreshTest(t: GlobalTestState) { // Set up test environment diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -27,6 +27,7 @@ import { codecOptional, } from "./codec.js"; import { + TalerFormAttributes, buildCodecForUnion, codecForAmountString, codecForBoolean, @@ -38,7 +39,6 @@ import { codecForURN, codecOptionalDefault, strcmp, - TalerFormAttributes, } from "./index.js"; import { Edx25519PublicKeyEnc } from "./taler-crypto.js"; import { @@ -274,7 +274,7 @@ export interface RecoupRequest { * * The string variant is for the legacy exchange protocol. */ - denom_sig: UnblindedSignature; + denom_sig: UnblindedDenominationSignature; /** * Blinding key that was used during withdraw, @@ -302,7 +302,7 @@ export interface RecoupRefreshRequest { * * The string variant is for the legacy exchange protocol. */ - denom_sig: UnblindedSignature; + denom_sig: UnblindedDenominationSignature; /** * Coin's blinding factor. @@ -334,7 +334,7 @@ export interface RecoupConfirmation { old_coin_pub?: string; } -export type UnblindedSignature = RsaUnblindedSignature; +export type UnblindedDenominationSignature = RsaUnblindedSignature; export interface RsaUnblindedSignature { cipher: DenomKeyType.Rsa; @@ -361,7 +361,7 @@ export interface CoinDepositPermission { * The string variant is for legacy protocol support. */ - ub_sig: UnblindedSignature; + ub_sig: UnblindedDenominationSignature; /** * The denomination public key associated with this coin. @@ -549,12 +549,59 @@ export interface ExchangeMeltRequest { coin_pub: CoinPublicKeyString; confirm_sig: EddsaSignatureString; denom_pub_hash: HashCodeString; - denom_sig: UnblindedSignature; + denom_sig: UnblindedDenominationSignature; rc: string; value_with_fee: AmountString; age_commitment_hash?: HashCodeString; } +/** + * Docs name: NewMeltRequest + */ +export interface ExchangeMeltRequestV2 { + // The old coin's public key + old_coin_pub: CoinPublicKeyString; + + // Hash of the denomination public key of the old coin, to determine total coin value. + old_denom_pub_h: HashCodeString; + + // The hash of the age-commitment for the old coin. Only present + // if the denomination has support for age restriction. + old_age_commitment_h?: string; + + // Signature over the old coin public key by the denomination. + old_denom_sig: UnblindedDenominationSignature; + + // Amount of the value of the old coin that should be melted as part of + // this refresh operation, including melting fee. + value_with_fee: Amount; + + // Array of n new hash codes of denomination public keys + // for the new coins to order. + denoms_h: HashCode[]; + + // Seed from which the nonces for the n*κ coin candidates are derived + // from. + refresh_seed: HashCode; + + // Master seed for the Clause-Schnorr R-value + // creation. Must match the /blinding-prepare request. + // Must not have been used in any prior melt request. + // Must be present if one of the fresh coin's + // denominations is of type Clause-Schnorr. + blinding_seed?: string; + + // kappa arrays of n entries for blinded coin candidates, + // each matching the respective entries in denoms_h. + // + // Note: These are essentially the m_i values in the RefreshDerivePQ + // function. + coin_evs: CoinEnvelope[][]; + + // Signature by the coin over TALER_RefreshMeltCoinAffirmationPS. + confirm_sig: EddsaSignatureString; +} + export interface ExchangeMeltResponse { /** * Which of the kappa indices does the client not have to reveal. @@ -919,6 +966,30 @@ export interface ExchangeRefreshRevealRequest { old_age_commitment?: Edx25519PublicKeyEnc[]; } +export interface ExchangeRefreshRevealRequestV2 { + // The commitment from the /melt/ step, + // i.e. the SHA512 value of + // 1. refresh_seed + // 2. blinding_seed, if applicable, skip otherwise + // 4. amount with fee (NBO) + // 5. kappa*n blinded planchet hashes (which include denomination information), + // depths first: [0..n)[0..n)[0..n) + rc: string; + + // The disclosed kappa-1 signatures by the old coin's private key, + // 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[]; + + // IFF the denomination of the old coin had support for age restriction, + // the client MUST provide the original age commitment, i. e. the + // vector of public keys, or omitted otherwise. + // The size of the vector MUST be the number of age groups as defined by the + // Exchange in the field .age_groups of the extension age_restriction. + age_commitment?: Edx25519PublicKeyEnc[]; +} + export const codecForRecoup = (): Codec<Recoup> => buildCodecForObject<Recoup>() .property("h_denom_pub", codecForString()) @@ -1044,6 +1115,12 @@ export const codecForExchangeWithdrawResponse = .property("ev_sigs", codecForList(codecForBlindedDenominationSignature())) .build("WithdrawResponse"); +export const codecForExchangeRevealMeltResponseV2 = + (): Codec<ExchangeWithdrawResponse> => + buildCodecForObject<ExchangeWithdrawResponse>() + .property("ev_sigs", codecForList(codecForBlindedDenominationSignature())) + .build("ExchangeRevealMeltResponseV2"); + export const codecForExchangeMeltResponse = (): Codec<ExchangeMeltResponse> => buildCodecForObject<ExchangeMeltResponse>() .property("exchange_pub", codecForString()) @@ -1100,7 +1177,7 @@ export interface PurseDeposit { /** * Exchange's unblinded RSA signature of the coin. */ - ub_sig: UnblindedSignature; + ub_sig: UnblindedDenominationSignature; /** * Age commitment for the coin, if the denomination is age-restricted. @@ -1269,7 +1346,7 @@ export interface ExchangeDepositRequest { denom_pub_hash: HashCodeString; // Exchange's unblinded RSA signature of the coin. - ub_sig: UnblindedSignature; + ub_sig: UnblindedDenominationSignature; // Timestamp when the contract was finalized. timestamp: TalerProtocolTimestamp; @@ -1481,7 +1558,7 @@ export interface BatchDepositRequestCoin { denom_pub_hash: HashCodeString; // Exchange's unblinded RSA signature of the coin. - ub_sig: UnblindedSignature; + ub_sig: UnblindedDenominationSignature; // Amount to be deposited, can be a fraction of the // coin's total value. @@ -1864,9 +1941,9 @@ export type ExchangeKycUploadFormRequest = { // Which form is being submitted. Further details depend on the form. // @since protocol v26. // form_id: string; - [TalerFormAttributes.FORM_ID] : string; - [TalerFormAttributes.FORM_VERSION] : number; -} + [TalerFormAttributes.FORM_ID]: string; + [TalerFormAttributes.FORM_VERSION]: number; +}; // Since **vATTEST**. export interface KycCheckPublicInformation { diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -35,7 +35,7 @@ import { ObjectCodec, PaytoString, TalerPreciseTimestamp, - UnblindedSignature, + UnblindedDenominationSignature, assertUnreachable, buildCodecForUnion, codecForAccountLimit, @@ -135,7 +135,7 @@ export interface CSTokenIssueBlindSig { export interface TokenUseSig { token_sig: EddsaSignatureString; token_pub: EddsaPublicKeyString; - ub_sig: UnblindedSignature; + ub_sig: UnblindedDenominationSignature; h_issue: string; } diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -79,12 +79,11 @@ import { ExchangeRefundRequest, ExchangeWireAccount, PeerContractTerms, - UnblindedSignature, + UnblindedDenominationSignature, codecForExchangeWireAccount, codecForPeerContractTerms, } from "./types-taler-exchange.js"; import { - MerchantContractChoice, MerchantContractTerms, MerchantContractTermsV0, MerchantContractTermsV1, @@ -1256,7 +1255,7 @@ export interface DepositInfo { wireInfoHash: string; denomKeyType: DenomKeyType; denomPubHash: string; - denomSig: UnblindedSignature; + denomSig: UnblindedDenominationSignature; requiredMinimumAge?: number; @@ -2390,7 +2389,7 @@ export type GetChoicesForPaymentResult = { * are payable. */ automaticExecution?: boolean; -} +}; export interface SharePaymentRequest { merchantBaseUrl: string; @@ -2562,6 +2561,7 @@ export interface RefreshPlanchetInfo { blindingKey: string; maxAge: number; + ageCommitmentProof?: AgeCommitmentProof; } diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -83,7 +83,6 @@ import { rsaBlind, rsaUnblind, rsaVerify, - setupTipPlanchet, SignTokenUseRequest, Slate, SlateCreationRequest, @@ -95,7 +94,7 @@ import { TokenEnvelope, TokenIssueBlindSig, TokenIssuePublicKey, - UnblindedSignature, + UnblindedDenominationSignature, WireFee, WithdrawalPlanchet, } from "@gnu-taler/taler-util"; @@ -107,9 +106,9 @@ import { DecryptContractRequest, DecryptContractResponse, DerivedRefreshSession, - DerivedTipPlanchet, + DerivedRefreshSessionV2, DeriveRefreshSessionRequest, - DeriveTipRequest, + DeriveRefreshSessionRequestV2, EncryptContractForDepositRequest, EncryptContractForDepositResponse, EncryptContractRequest, @@ -151,11 +150,6 @@ export interface TalerCryptoInterface { eddsaSign(req: EddsaSignRequest): Promise<EddsaSignResponse>; /** - * Create a planchet used for tipping, including the private keys. - */ - createTipPlanchet(req: DeriveTipRequest): Promise<DerivedTipPlanchet>; - - /** * Create a pre-token (a.k.a. slate) of a given token family. */ createSlate(req: SlateCreationRequest): Promise<Slate>; @@ -202,11 +196,11 @@ export interface TalerCryptoInterface { unblindDenominationSignature( req: UnblindDenominationSignatureRequest, - ): Promise<UnblindedSignature>; + ): Promise<UnblindedDenominationSignature>; unblindTokenIssueSignature( req: UnblindTokenIssueSignatureRequest, - ): Promise<UnblindedSignature>; + ): Promise<UnblindedDenominationSignature>; rsaUnblind(req: RsaUnblindRequest): Promise<RsaUnblindResponse>; @@ -222,6 +216,10 @@ export interface TalerCryptoInterface { req: DeriveRefreshSessionRequest, ): Promise<DerivedRefreshSession>; + deriveRefreshSessionV2( + req: DeriveRefreshSessionRequestV2, + ): Promise<DerivedRefreshSessionV2>; + hashString(req: HashStringRequest): Promise<HashStringResult>; signCoinLink(req: SignCoinLinkRequest): Promise<EddsaSigningResult>; @@ -317,11 +315,6 @@ export const nullCrypto: TalerCryptoInterface = { eddsaSign: function (req: EddsaSignRequest): Promise<EddsaSignResponse> { throw new Error("Function not implemented."); }, - createTipPlanchet: function ( - req: DeriveTipRequest, - ): Promise<DerivedTipPlanchet> { - throw new Error("Function not implemented."); - }, createSlate: function (req: SlateCreationRequest): Promise<Slate> { throw new Error("Function not implemented."); }, @@ -390,12 +383,12 @@ export const nullCrypto: TalerCryptoInterface = { }, unblindDenominationSignature: function ( req: UnblindDenominationSignatureRequest, - ): Promise<UnblindedSignature> { + ): Promise<UnblindedDenominationSignature> { throw new Error("Function not implemented."); }, unblindTokenIssueSignature: function ( req: UnblindTokenIssueSignatureRequest, - ): Promise<UnblindedSignature> { + ): Promise<UnblindedDenominationSignature> { throw new Error("Function not implemented."); }, rsaUnblind: function (req: RsaUnblindRequest): Promise<RsaUnblindResponse> { @@ -533,6 +526,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<SignWithdrawalResponse> { throw new Error("Function not implemented."); }, + deriveRefreshSessionV2: function ( + req: DeriveRefreshSessionRequestV2, + ): Promise<DerivedRefreshSessionV2> { + throw new Error("Function not implemented."); + }, }; export type WithArg<X> = X extends (req: infer T) => infer R @@ -608,7 +606,7 @@ export interface SpendCoinDetails { contribution: AmountString; feeDeposit: AmountString; denomPubHash: string; - denomSig: UnblindedSignature; + denomSig: UnblindedDenominationSignature; ageCommitmentProof: AgeCommitmentProof | undefined; } @@ -685,7 +683,7 @@ export interface PaymentSignatureValidationRequest { export interface TokenSignatureValidationRequest { tokenUsePub: string; tokenIssuePub: TokenIssuePublicKey; - sig: UnblindedSignature; + sig: UnblindedDenominationSignature; } export interface ContractTermsValidationRequest { @@ -939,45 +937,6 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { } }, - async createTipPlanchet( - tci: TalerCryptoInterfaceR, - req: DeriveTipRequest, - ): Promise<DerivedTipPlanchet> { - if (req.denomPub.cipher !== DenomKeyType.Rsa) { - throw Error(`unsupported cipher (${req.denomPub.cipher})`); - } - const fc = await setupTipPlanchet( - decodeCrock(req.secretSeed), - req.denomPub, - req.planchetIndex, - ); - const maybeAch = fc.ageCommitmentProof - ? AgeRestriction.hashCommitment(fc.ageCommitmentProof.commitment) - : undefined; - const denomPub = decodeCrock(req.denomPub.rsa_public_key); - const coinPubHash = hashCoinPub(encodeCrock(fc.coinPub), maybeAch); - const blindResp = await tci.rsaBlind(tci, { - bks: encodeCrock(fc.bks), - hm: encodeCrock(coinPubHash), - pub: encodeCrock(denomPub), - }); - const coinEv = { - cipher: DenomKeyType.Rsa, - rsa_blinded_planchet: blindResp.blinded, - }; - const tipPlanchet: DerivedTipPlanchet = { - blindingKey: encodeCrock(fc.bks), - coinEv, - coinEvHash: encodeCrock( - hashCoinEv(coinEv, encodeCrock(hashDenomPub(req.denomPub))), - ), - coinPriv: encodeCrock(fc.coinPriv), - coinPub: encodeCrock(fc.coinPub), - ageCommitmentProof: fc.ageCommitmentProof, - }; - return tipPlanchet; - }, - async createSlate( tci: TalerCryptoInterfaceR, req: SlateCreationRequest, @@ -1328,7 +1287,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { async unblindDenominationSignature( tci: TalerCryptoInterfaceR, req: UnblindDenominationSignatureRequest, - ): Promise<UnblindedSignature> { + ): Promise<UnblindedDenominationSignature> { if (req.evSig.cipher === DenomKeyType.Rsa) { if (req.planchet.denomPub.cipher !== DenomKeyType.Rsa) { throw new Error( @@ -1352,7 +1311,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { async unblindTokenIssueSignature( tci: TalerCryptoInterfaceR, req: UnblindTokenIssueSignatureRequest, - ): Promise<UnblindedSignature> { + ): Promise<UnblindedDenominationSignature> { if (req.evSig.cipher === DenomKeyType.Rsa) { if (req.slate.tokenIssuePub.cipher !== DenomKeyType.Rsa) { throw new Error("slate cipher does not match blind signature cipher"); @@ -1488,6 +1447,13 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { } }, + async deriveRefreshSessionV2( + tci: TalerCryptoInterfaceR, + req: DeriveRefreshSessionRequestV2, + ): Promise<DerivedRefreshSessionV2> { + throw new Error("Function not implemented."); + }, + async deriveRefreshSession( tci: TalerCryptoInterfaceR, req: DeriveRefreshSessionRequest, @@ -1638,7 +1604,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { confirmSig: confirmSigResp.sig, hash: encodeCrock(sessionHash), meltCoinPub: meltCoinPub, - planchetsForGammas: planchetsForGammas, + planchets: planchetsForGammas, transferPrivs, transferPubs, meltValueWithFee: valueWithFee, diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -31,7 +31,6 @@ import { AgeCommitmentProof, AmountJson, AmountString, - CoinEnvelope, DenominationPubKey, EddsaPrivateKeyString, EddsaPublicKeyString, @@ -40,7 +39,7 @@ import { HashCodeString, RefreshPlanchetInfo, TalerProtocolTimestamp, - UnblindedSignature, + UnblindedDenominationSignature, WalletAccountMergeFlags, } from "@gnu-taler/taler-util"; @@ -70,8 +69,23 @@ 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. */ +export interface DeriveRefreshSessionRequestV2 { + 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. @@ -86,7 +100,7 @@ export interface DerivedRefreshSession { /** * Planchets for each cut-and-choose instance. */ - planchetsForGammas: RefreshPlanchetInfo[][]; + planchets: RefreshPlanchetInfo[][]; /** * The transfer keys, kappa of them. @@ -109,22 +123,36 @@ export interface DerivedRefreshSession { meltValueWithFee: AmountJson; } -export interface DeriveTipRequest { - secretSeed: string; - denomPub: DenominationPubKey; - planchetIndex: number; -} +export interface DerivedRefreshSessionV2 { + /** + * Public key that's being melted in this session. + */ + meltCoinPub: string; -/** - * Tipping planchet stored in the database. - */ -export interface DerivedTipPlanchet { - blindingKey: string; - coinEv: CoinEnvelope; - coinEvHash: string; - coinPriv: string; - coinPub: string; - ageCommitmentProof: AgeCommitmentProof | undefined; + /** + * Signature to confirm the melting. + */ + confirmSig: string; + + /** + * Planchets for each cut-and-choose instance. + */ + planchets: RefreshPlanchetInfo[][]; + + /** + * kappa signatures by the old coin private key. + */ + signatures: EddsaSignatureString[]; + + /** + * Hash of the session. + */ + hash: string; + + /** + * Exact value that is being melted. + */ + meltValueWithFee: AmountJson; } export interface SignTrackTransactionRequest { @@ -144,7 +172,7 @@ export interface CreateRecoupReqRequest { blindingKey: string; denomPub: DenominationPubKey; denomPubHash: string; - denomSig: UnblindedSignature; + denomSig: UnblindedDenominationSignature; } /** @@ -156,7 +184,7 @@ export interface CreateRecoupRefreshReqRequest { blindingKey: string; denomPub: DenominationPubKey; denomPubHash: string; - denomSig: UnblindedSignature; + denomSig: UnblindedDenominationSignature; } export interface EncryptedContract { diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -65,7 +65,7 @@ import { TokenIssuePublicKey, TokenUseSig, TransactionIdStr, - UnblindedSignature, + UnblindedDenominationSignature, WireInfo, WithdrawalExchangeAccountDetails, ZeroLimitedOperation, @@ -902,7 +902,7 @@ export interface CoinRecord { /** * Unblinded signature by the exchange. */ - denomSig: UnblindedSignature; + denomSig: UnblindedDenominationSignature; /** * Base URL that identifies the exchange from which we got the @@ -1026,13 +1026,13 @@ export interface TokenRecord { /** * End time of the token family's validity period. - */ + */ validBefore: DbProtocolTimestamp; /** * Unblinded token issue signature made by the merchant. */ - tokenIssueSig: UnblindedSignature; + tokenIssueSig: UnblindedDenominationSignature; /** * Token use public key used to confirm usage of tokens. @@ -1070,7 +1070,7 @@ export interface TokenRecord { * also the database representation of a token before being * signed by the merchant, as stored in the `slates' data store. */ -export type SlateRecord = Omit<TokenRecord, 'tokenIssueSig'>; +export type SlateRecord = Omit<TokenRecord, "tokenIssueSig">; /** * History item for a coin. @@ -1233,8 +1233,15 @@ export interface RefreshSessionRecord { /** * 512-bit secret that can be used to derive * the other cryptographic material for the refresh session. + * + * If this field is set, it's a legacy V1 refresh session. + */ + sessionSecretSeed?: string; + + /** + * If this field is set, it's a V2 refresh session. */ - sessionSecretSeed: string; + sessionPublicSeed?: string; /** * Sum of the value of denominations we want @@ -2857,12 +2864,15 @@ export const WalletStoresV1 = { keyPath: "tokenUsePub", }), { - byTokenIssuePubHash: describeIndex("byTokenIssuePubHash", "tokenIssuePubHash"), - byPurchaseIdAndChoiceIndex: describeIndex( - "byPurchaseIdAndChoiceIndex", - ["purchaseId", "choiceIndex"], + byTokenIssuePubHash: describeIndex( + "byTokenIssuePubHash", + "tokenIssuePubHash", ), - } + byPurchaseIdAndChoiceIndex: describeIndex("byPurchaseIdAndChoiceIndex", [ + "purchaseId", + "choiceIndex", + ]), + }, ), slates: describeStore( "slates", @@ -2870,13 +2880,13 @@ export const WalletStoresV1 = { keyPath: "tokenUsePub", }), { - byPurchaseIdAndChoiceIndex: describeIndex( - "byPurchaseIdAndChoiceIndex", - ["purchaseId", "choiceIndex"], - ), + byPurchaseIdAndChoiceIndex: describeIndex("byPurchaseIdAndChoiceIndex", [ + "purchaseId", + "choiceIndex", + ]), byPurchaseIdAndChoiceIndexAndOutputIndex: describeIndex( "byPurchaseIdAndChoiceIndexAndOutputIndex", - ["purchaseId", "choiceIndex", "outputIndex"] + ["purchaseId", "choiceIndex", "outputIndex"], ), }, ), diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts @@ -40,7 +40,7 @@ import { ExchangeProtocolVersion, Logger, TalerCorebankApiClient, - UnblindedSignature, + UnblindedDenominationSignature, codecForAny, codecForBankWithdrawalOperationPostResponse, codecForBatchDepositSuccess, @@ -79,7 +79,7 @@ export interface CoinInfo { coinPub: string; coinPriv: string; exchangeBaseUrl: string; - denomSig: UnblindedSignature; + denomSig: UnblindedDenominationSignature; denomPub: DenominationPubKey; denomPubHash: string; feeDeposit: string; diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -26,12 +26,15 @@ import { AgeCommitment, AgeRestriction, AmountJson, + AmountLike, Amounts, amountToPretty, assertUnreachable, + BlindedDenominationSignature, checkDbInvariant, codecForCoinHistoryResponse, codecForExchangeMeltResponse, + codecForExchangeRevealMeltResponseV2, codecForExchangeRevealResponse, CoinPublicKeyString, CoinRefreshRequest, @@ -41,8 +44,10 @@ import { Duration, encodeCrock, ExchangeMeltRequest, + ExchangeMeltRequestV2, ExchangeProtocolVersion, ExchangeRefreshRevealRequest, + ExchangeRefreshRevealRequestV2, ExchangeRefundRequest, fnutil, ForceRefreshRequest, @@ -51,9 +56,11 @@ import { HashCodeString, HttpStatusCode, j2s, + LibtoolVersion, Logger, makeErrorDetail, NotificationType, + RefreshPlanchetInfo, RefreshReason, TalerErrorCode, TalerErrorDetail, @@ -111,7 +118,7 @@ import { WalletDbStoresArr, } from "./db.js"; import { selectWithdrawalDenominations } from "./denomSelection.js"; -import { getScopeForAllExchanges } from "./exchanges.js"; +import { fetchFreshExchange, getScopeForAllExchanges } from "./exchanges.js"; import { BalanceEffect, constructTransactionIdentifier, @@ -523,8 +530,6 @@ async function initRefreshSession( const exchangeBaseUrl = oldCoin.exchangeBaseUrl; - const sessionSecretSeed = encodeCrock(getRandomBytes(64)); - const oldDenom = await getDenomInfo( wex, tx, @@ -588,7 +593,6 @@ async function initRefreshSession( coinIndex, refreshGroupId, norevealIndex: undefined, - sessionSecretSeed: sessionSecretSeed, newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({ count: x.count, denomPubHash: x.denomPubHash, @@ -727,35 +731,53 @@ async function refreshMelt( return; } - const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d; - - let exchangeProtocolVersion: ExchangeProtocolVersion; - switch (d.oldDenom.denomPub.cipher) { - case DenomKeyType.Rsa: { - exchangeProtocolVersion = ExchangeProtocolVersion.V12; - break; + // Make sure that we have a seed. + if ( + d.refreshSession.sessionPublicSeed == null && + d.refreshSession.sessionSecretSeed == null + ) { + const exchange = await fetchFreshExchange(wex, d.oldCoin.exchangeBaseUrl); + const exchangeVer = LibtoolVersion.parseVersion( + exchange.protocolVersionRange, + ); + checkDbInvariant(!!exchangeVer, "bad exchange version string"); + const seed = encodeCrock(getRandomBytes(64)); + const updatedSession = await wex.db.runReadWriteTx( + { + storeNames: ["refreshSessions"], + }, + async (tx) => { + const refreshSession = await tx.refreshSessions.get([ + refreshGroupId, + coinIndex, + ]); + if (!refreshSession) { + return undefined; + } + if ( + refreshSession.sessionPublicSeed != null || + refreshSession.sessionSecretSeed != null + ) { + return refreshSession; + } + if (exchangeVer.current >= 27) { + refreshSession.sessionPublicSeed = seed; + } else { + refreshSession.sessionSecretSeed = seed; + } + await tx.refreshSessions.put(refreshSession); + return refreshSession; + }, + ); + if (!updatedSession) { + throw Error( + "Could not update refresh session (concurrent deletion?).", + ); } - default: - throw Error("unsupported key type"); + d.refreshSession = updatedSession; } - const derived = await wex.cryptoApi.deriveRefreshSession({ - exchangeProtocolVersion, - kappa: 3, - meltCoinDenomPubHash: oldCoin.denomPubHash, - meltCoinPriv: oldCoin.coinPriv, - meltCoinPub: oldCoin.coinPub, - feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh), - meltCoinMaxAge: oldCoin.maxAge, - meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, - newCoinDenoms, - sessionSecretSeed: refreshSession.sessionSecretSeed, - }); - - const reqUrl = new URL( - `coins/${oldCoin.coinPub}/melt`, - oldCoin.exchangeBaseUrl, - ); + const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d; let maybeAch: HashCodeString | undefined; if (oldCoin.ageCommitmentProof) { @@ -764,88 +786,221 @@ async function refreshMelt( ); } - const meltReqBody: ExchangeMeltRequest = { - coin_pub: oldCoin.coinPub, - confirm_sig: derived.confirmSig, - denom_pub_hash: oldCoin.denomPubHash, - denom_sig: oldCoin.denomSig, - rc: derived.hash, - value_with_fee: Amounts.stringify(derived.meltValueWithFee), - age_commitment_hash: maybeAch, - }; + if (refreshSession.sessionSecretSeed) { + // Old legacy melt protocol. + let exchangeProtocolVersion: ExchangeProtocolVersion; + switch (d.oldDenom.denomPub.cipher) { + case DenomKeyType.Rsa: { + exchangeProtocolVersion = ExchangeProtocolVersion.V12; + break; + } + default: + throw Error("unsupported key type"); + } - const resp = await wex.ws.runSequentialized( - [EXCHANGE_COINS_LOCK], - async () => { - return await cancelableFetch(wex, reqUrl, { - method: "POST", - body: meltReqBody, - timeout: getRefreshRequestTimeout(refreshGroup) - }); - }, - ); + const derived = await wex.cryptoApi.deriveRefreshSession({ + exchangeProtocolVersion, + kappa: 3, + meltCoinDenomPubHash: oldCoin.denomPubHash, + meltCoinPriv: oldCoin.coinPriv, + meltCoinPub: oldCoin.coinPub, + feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh), + meltCoinMaxAge: oldCoin.maxAge, + meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, + newCoinDenoms, + sessionSecretSeed: refreshSession.sessionSecretSeed, + }); - switch (resp.status) { - case HttpStatusCode.NotFound: { - const errDetail = await readTalerErrorResponse(resp); - await handleRefreshMeltNotFound(ctx, coinIndex, resp, errDetail); - return; - } - case HttpStatusCode.Gone: { - const errDetail = await readTalerErrorResponse(resp); - await handleRefreshMeltGone(ctx, coinIndex, errDetail); - return; - } - case HttpStatusCode.Conflict: { - const errDetail = await readTalerErrorResponse(resp); - await handleRefreshMeltConflict( - ctx, - refreshGroup, - coinIndex, - errDetail, - derived, - oldCoin, - ); - return; - } - case HttpStatusCode.Ok: - break; - default: { - const errDetail = await readTalerErrorResponse(resp); - throwUnexpectedRequestError(resp, errDetail); + const reqUrl = new URL( + `coins/${oldCoin.coinPub}/melt`, + oldCoin.exchangeBaseUrl, + ); + + const meltReqBody: ExchangeMeltRequest = { + coin_pub: oldCoin.coinPub, + confirm_sig: derived.confirmSig, + denom_pub_hash: oldCoin.denomPubHash, + denom_sig: oldCoin.denomSig, + rc: derived.hash, + value_with_fee: Amounts.stringify(derived.meltValueWithFee), + age_commitment_hash: maybeAch, + }; + + const resp = await wex.ws.runSequentialized( + [EXCHANGE_COINS_LOCK], + async () => { + return await cancelableFetch(wex, reqUrl, { + method: "POST", + body: meltReqBody, + timeout: getRefreshRequestTimeout(refreshGroup), + }); + }, + ); + + switch (resp.status) { + case HttpStatusCode.NotFound: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshMeltNotFound(ctx, coinIndex, resp, errDetail); + return; + } + case HttpStatusCode.Gone: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshMeltGone(ctx, coinIndex, errDetail); + return; + } + case HttpStatusCode.Conflict: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshMeltConflict( + ctx, + refreshGroup, + coinIndex, + errDetail, + derived.meltValueWithFee, + oldCoin, + ); + return; + } + case HttpStatusCode.Ok: + break; + default: { + const errDetail = await readTalerErrorResponse(resp); + throwUnexpectedRequestError(resp, errDetail); + } } - } - const meltResponse = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeMeltResponse(), - ); + const meltResponse = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeMeltResponse(), + ); - const norevealIndex = meltResponse.noreveal_index; + const norevealIndex = meltResponse.noreveal_index; - refreshSession.norevealIndex = norevealIndex; + refreshSession.norevealIndex = norevealIndex; - await wex.db.runReadWriteTx( - { storeNames: ["refreshGroups", "refreshSessions"] }, - async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { + await wex.db.runReadWriteTx( + { storeNames: ["refreshGroups", "refreshSessions"] }, + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + return; + } + if (rg.timestampFinished) { + return; + } + const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); + if (!rs) { + return; + } + if (rs.norevealIndex !== undefined) { + return; + } + rs.norevealIndex = norevealIndex; + await tx.refreshSessions.put(rs); + }, + ); + } else if (refreshSession.sessionPublicSeed) { + // 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, + newCoinDenoms, + sessionPublicSeed: refreshSession.sessionPublicSeed, + }); + + const reqUrl = new URL(`melt`, oldCoin.exchangeBaseUrl); + const meltReqBody: ExchangeMeltRequestV2 = { + old_coin_pub: oldCoin.coinPub, + old_denom_pub_h: oldCoin.denomPubHash, + old_denom_sig: oldCoin.denomSig, + old_age_commitment_h: maybeAch, + refresh_seed: refreshSession.sessionPublicSeed, + confirm_sig: derived.confirmSig, + coin_evs: derived.planchets.map((x) => x.map((y) => y.coinEv)), + denoms_h: newCoinDenoms.map((x) => x.denomPubHash), + value_with_fee: Amounts.stringify(derived.meltValueWithFee), + }; + const resp = await wex.ws.runSequentialized( + [EXCHANGE_COINS_LOCK], + async () => { + return await cancelableFetch(wex, reqUrl, { + method: "POST", + body: meltReqBody, + timeout: getRefreshRequestTimeout(refreshGroup), + }); + }, + ); + + switch (resp.status) { + case HttpStatusCode.NotFound: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshMeltNotFound(ctx, coinIndex, resp, errDetail); return; } - if (rg.timestampFinished) { + case HttpStatusCode.Gone: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshMeltGone(ctx, coinIndex, errDetail); return; } - const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); - if (!rs) { + case HttpStatusCode.Conflict: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshMeltConflict( + ctx, + refreshGroup, + coinIndex, + errDetail, + derived.meltValueWithFee, + oldCoin, + ); return; } - if (rs.norevealIndex !== undefined) { - return; + case HttpStatusCode.Ok: + break; + default: { + const errDetail = await readTalerErrorResponse(resp); + throwUnexpectedRequestError(resp, errDetail); } - rs.norevealIndex = norevealIndex; - await tx.refreshSessions.put(rs); - }, - ); + } + + const meltResponse = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeMeltResponse(), + ); + + // FIXME: Check exchange's signature. + + const norevealIndex = meltResponse.noreveal_index; + + refreshSession.norevealIndex = norevealIndex; + + await wex.db.runReadWriteTx( + { storeNames: ["refreshGroups", "refreshSessions"] }, + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + return; + } + if (rg.timestampFinished) { + return; + } + const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); + if (!rs) { + return; + } + if (rs.norevealIndex !== undefined) { + return; + } + rs.norevealIndex = norevealIndex; + await tx.refreshSessions.put(rs); + }, + ); + } else { + throw Error("unsupported refresh session (neither secret nor public seed)"); + } } async function handleRefreshMeltGone( @@ -901,14 +1056,14 @@ async function handleRefreshMeltConflict( refreshGroup: RefreshGroupRecord, coinIndex: number, errDetails: TalerErrorDetail, - derived: DerivedRefreshSession, + meltValueWithFee: AmountLike, oldCoin: CoinRecord, ): Promise<void> { // Just log for better diagnostics here, error status // will be handled later. logger.error( `melt request for ${Amounts.stringify( - derived.meltValueWithFee, + meltValueWithFee, )} failed in refresh group ${ctx.refreshGroupId} due to conflict`, ); @@ -1110,7 +1265,7 @@ export async function assembleRefreshRevealRequest(args: { const privs = Array.from(derived.transferPrivs); privs.splice(norevealIndex, 1); - const planchets = derived.planchetsForGammas[norevealIndex]; + const planchets = derived.planchets[norevealIndex]; if (!planchets) { throw Error("refresh index error"); } @@ -1238,89 +1393,146 @@ async function refreshReveal( norevealIndex, } = d; - let exchangeProtocolVersion: ExchangeProtocolVersion; - switch (d.oldDenom.denomPub.cipher) { - case DenomKeyType.Rsa: { - exchangeProtocolVersion = ExchangeProtocolVersion.V12; - break; + // Blinded signatures, either from the old or the new reveal protocol. + let resEvSigs: BlindedDenominationSignature[]; + let planchets: RefreshPlanchetInfo[][]; + + if (refreshSession.sessionSecretSeed != null) { + // Legacy refresh session. + + let exchangeProtocolVersion: ExchangeProtocolVersion; + switch (d.oldDenom.denomPub.cipher) { + case DenomKeyType.Rsa: { + exchangeProtocolVersion = ExchangeProtocolVersion.V12; + break; + } + default: + throw Error("unsupported key type"); } - default: - throw Error("unsupported key type"); - } - const derived = await wex.cryptoApi.deriveRefreshSession({ - exchangeProtocolVersion, - kappa: 3, - meltCoinDenomPubHash: oldCoin.denomPubHash, - meltCoinPriv: oldCoin.coinPriv, - meltCoinPub: oldCoin.coinPub, - feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh), - newCoinDenoms, - meltCoinMaxAge: oldCoin.maxAge, - meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, - sessionSecretSeed: refreshSession.sessionSecretSeed, - }); + const derived = await wex.cryptoApi.deriveRefreshSession({ + exchangeProtocolVersion, + kappa: 3, + meltCoinDenomPubHash: oldCoin.denomPubHash, + meltCoinPriv: oldCoin.coinPriv, + meltCoinPub: oldCoin.coinPub, + feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh), + newCoinDenoms, + meltCoinMaxAge: oldCoin.maxAge, + meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, + sessionSecretSeed: refreshSession.sessionSecretSeed, + }); - const reqUrl = new URL( - `refreshes/${derived.hash}/reveal`, - oldCoin.exchangeBaseUrl, - ); + const reqUrl = new URL( + `refreshes/${derived.hash}/reveal`, + oldCoin.exchangeBaseUrl, + ); - const req = await assembleRefreshRevealRequest({ - cryptoApi: wex.cryptoApi, - derived, - newDenoms: newCoinDenoms, - norevealIndex: norevealIndex, - oldCoinPriv: oldCoin.coinPriv, - oldCoinPub: oldCoin.coinPub, - oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment, - }); + const req = await assembleRefreshRevealRequest({ + cryptoApi: wex.cryptoApi, + derived, + newDenoms: newCoinDenoms, + norevealIndex: norevealIndex, + oldCoinPriv: oldCoin.coinPriv, + oldCoinPub: oldCoin.coinPub, + oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment, + }); - const resp = await wex.ws.runSequentialized( - [EXCHANGE_COINS_LOCK], - async () => cancelableFetch(wex, reqUrl, { - body: req, - method: "POST", - timeout: getRefreshRequestTimeout(refreshGroup) - }), - ); + const resp = await wex.ws.runSequentialized( + [EXCHANGE_COINS_LOCK], + async () => + cancelableFetch(wex, reqUrl, { + body: req, + method: "POST", + timeout: getRefreshRequestTimeout(refreshGroup), + }), + ); - switch (resp.status) { - case HttpStatusCode.Ok: - break; - case HttpStatusCode.Conflict: - case HttpStatusCode.Gone: { - const errDetail = await readTalerErrorResponse(resp); - await handleRefreshRevealError(ctx, coinIndex, errDetail); - return; + switch (resp.status) { + case HttpStatusCode.Ok: + break; + case HttpStatusCode.Conflict: + case HttpStatusCode.Gone: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshRevealError(ctx, coinIndex, errDetail); + return; + } + default: { + const errDetail = await readTalerErrorResponse(resp); + throwUnexpectedRequestError(resp, errDetail); + } } - default: { - const errDetail = await readTalerErrorResponse(resp); - throwUnexpectedRequestError(resp, errDetail); + + const reveal = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeRevealResponse(), + ); + planchets = derived.planchets; + resEvSigs = reveal.ev_sigs.map((x) => x.ev_sig); + } else if (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), + newCoinDenoms, + meltCoinMaxAge: oldCoin.maxAge, + meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, + }); + const req: ExchangeRefreshRevealRequestV2 = { + rc: derived.hash, + signatures: derived.signatures.filter((v, i) => i != norevealIndex), + age_commitment: oldCoin.ageCommitmentProof?.commitment?.publicKeys, + }; + const reqUrl = new URL(`reveal-melt`, oldCoin.exchangeBaseUrl); + const resp = await wex.ws.runSequentialized( + [EXCHANGE_COINS_LOCK], + async () => + cancelableFetch(wex, reqUrl, { + body: req, + method: "POST", + timeout: getRefreshRequestTimeout(refreshGroup), + }), + ); + + switch (resp.status) { + case HttpStatusCode.Ok: + break; + case HttpStatusCode.Conflict: + case HttpStatusCode.Gone: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshRevealError(ctx, coinIndex, errDetail); + return; + } + default: { + const errDetail = await readTalerErrorResponse(resp); + throwUnexpectedRequestError(resp, errDetail); + } } - } - const reveal = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeRevealResponse(), - ); + const reveal = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeRevealMeltResponseV2(), + ); + resEvSigs = reveal.ev_sigs; + planchets = derived.planchets; + } else { + throw Error("refresh session not supported"); + } const coins: CoinRecord[] = []; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - for (let i = 0; i < refreshSession.newDenoms.length; i++) { const ncd = newCoinDenoms[i]; for (let j = 0; j < refreshSession.newDenoms[i].count; j++) { const newCoinIndex = coins.length; - const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex]; + const pc = planchets[norevealIndex][newCoinIndex]; if (ncd.denomPub.cipher !== DenomKeyType.Rsa) { throw Error("cipher unsupported"); } - const evSig = reveal.ev_sigs[newCoinIndex].ev_sig; + const evSig = resEvSigs[newCoinIndex]; const denomSig = await wex.cryptoApi.unblindDenominationSignature({ planchet: { blindingKey: pc.blindingKey, @@ -1341,7 +1553,7 @@ async function refreshReveal( refreshGroupId, oldCoinPub: refreshGroup.oldCoinPubs[coinIndex], }, - sourceTransactionId: transactionId, + sourceTransactionId: ctx.transactionId, coinEvHash: pc.coinEvHash, maxAge: pc.maxAge, ageCommitmentProof: pc.ageCommitmentProof, diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -78,7 +78,7 @@ import { TransactionType, TransactionWithdrawal, URL, - UnblindedSignature, + UnblindedDenominationSignature, WalletNotification, WithdrawUriInfoResponse, WithdrawalDetailsForAmount, @@ -1916,7 +1916,7 @@ async function processPlanchetVerifyAndStoreCoin( return; } - let denomSig: UnblindedSignature; + let denomSig: UnblindedDenominationSignature; if (planchetDenomPub.cipher === DenomKeyType.Rsa) { denomSig = { cipher: planchetDenomPub.cipher, @@ -2629,7 +2629,13 @@ async function processWithdrawalGroupPendingReady( for (let i = 0; i < numTotalCoins; i += maxBatchSize) { let resp: WithdrawalBatchResult; if (exchangeVer.current >= 26) { - resp = await processPlanchetExchangeBatchRequest(wex, wgContext, { + logger.warn("new exchange version, but still using old batch request"); + // resp = await processPlanchetExchangeBatchRequest(wex, wgContext, { + // batchSize: maxBatchSize, + // coinStartIndex: i, + // }); + // FIXME: Use new batch request here! + resp = await processPlanchetExchangeLegacyBatchRequest(wex, wgContext, { batchSize: maxBatchSize, coinStartIndex: i, });