diff options
Diffstat (limited to 'packages/taler-wallet-core')
-rw-r--r-- | packages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 132 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/crypto/cryptoTypes.ts | 83 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 40 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/backup/import.ts | 52 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/pay.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/peer-to-peer.ts | 270 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/util/contractTerms.test.ts | 122 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/util/contractTerms.ts | 230 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/wallet-api-types.ts | 11 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/wallet.ts | 17 |
10 files changed, 568 insertions, 391 deletions
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts index 1d3641836..c177a51dd 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -33,10 +33,12 @@ import { BlindedDenominationSignature, bufferForUint32, buildSigPS, + bytesToString, CoinDepositPermission, CoinEnvelope, createHashContext, decodeCrock, + decryptContractForMerge, DenomKeyType, DepositInfo, ecdheGetPublic, @@ -45,6 +47,7 @@ import { eddsaSign, eddsaVerify, encodeCrock, + encryptContractForMerge, ExchangeProtocolVersion, getRandomBytes, hash, @@ -81,10 +84,17 @@ import { DenominationRecord, WireFee } from "../db.js"; import { CreateRecoupRefreshReqRequest, CreateRecoupReqRequest, + DecryptContractRequest, + DecryptContractResponse, DerivedRefreshSession, DerivedTipPlanchet, DeriveRefreshSessionRequest, DeriveTipRequest, + EncryptContractRequest, + EncryptContractResponse, + EncryptedContract, + SignPurseMergeRequest, + SignPurseMergeResponse, SignTrackTransactionRequest, } from "./cryptoTypes.js"; @@ -185,6 +195,16 @@ export interface TalerCryptoInterface { signPurseDeposits( req: SignPurseDepositsRequest, ): Promise<SignPurseDepositsResponse>; + + encryptContractForMerge( + req: EncryptContractRequest, + ): Promise<EncryptContractResponse>; + + decryptContractForMerge( + req: DecryptContractRequest, + ): Promise<DecryptContractResponse>; + + signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>; } /** @@ -326,6 +346,21 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<SignPurseDepositsResponse> { throw new Error("Function not implemented."); }, + encryptContractForMerge: function ( + req: EncryptContractRequest, + ): Promise<EncryptContractResponse> { + throw new Error("Function not implemented."); + }, + decryptContractForMerge: function ( + req: DecryptContractRequest, + ): Promise<DecryptContractResponse> { + throw new Error("Function not implemented."); + }, + signPurseMerge: function ( + req: SignPurseMergeRequest, + ): Promise<SignPurseMergeResponse> { + throw new Error("Function not implemented."); + }, }; export type WithArg<X> = X extends (req: infer T) => infer R @@ -502,6 +537,9 @@ export interface TransferPubResponse { transferPriv: string; } +/** + * JS-native implementation of the Taler crypto worker operations. + */ export const nativeCryptoR: TalerCryptoInterfaceR = { async eddsaSign( tci: TalerCryptoInterfaceR, @@ -960,9 +998,11 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { maybeAgeCommitmentHash = ach; hAgeCommitment = decodeCrock(ach); if (depositInfo.requiredMinimumAge != null) { - minimumAgeSig = AgeRestriction.commitmentAttest( - depositInfo.ageCommitmentProof, - depositInfo.requiredMinimumAge, + minimumAgeSig = encodeCrock( + AgeRestriction.commitmentAttest( + depositInfo.ageCommitmentProof, + depositInfo.requiredMinimumAge, + ), ); } } else { @@ -1094,7 +1134,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { if (req.meltCoinAgeCommitmentProof) { newAc = await AgeRestriction.commitmentDerive( req.meltCoinAgeCommitmentProof, - transferSecretRes.h, + decodeCrock(transferSecretRes.h), ); newAch = AgeRestriction.hashCommitment(newAc.commitment); } @@ -1280,6 +1320,9 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { for (const c of req.coins) { const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT) .put(amountToBuffer(Amounts.parseOrThrow(c.contribution))) + .put(decodeCrock(c.denomPubHash)) + // FIXME: use h_age_commitment here + .put(new Uint8Array(32)) .put(decodeCrock(req.pursePub)) .put(hExchangeBaseUrl) .build(); @@ -1300,6 +1343,87 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { deposits, }; }, + async encryptContractForMerge( + tci: TalerCryptoInterfaceR, + req: EncryptContractRequest, + ): Promise<EncryptContractResponse> { + const contractKeyPair = await this.createEddsaKeypair(tci, {}); + const enc = await encryptContractForMerge( + decodeCrock(req.pursePub), + decodeCrock(contractKeyPair.priv), + decodeCrock(req.mergePriv), + req.contractTerms, + ); + const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_ECONTRACT) + .put(hash(enc)) + .put(decodeCrock(contractKeyPair.pub)) + .build(); + const sig = eddsaSign(sigBlob, decodeCrock(req.pursePriv)); + return { + econtract: { + contract_pub: contractKeyPair.pub, + econtract: encodeCrock(enc), + econtract_sig: encodeCrock(sig), + }, + contractPriv: contractKeyPair.priv, + }; + }, + async decryptContractForMerge( + tci: TalerCryptoInterfaceR, + req: DecryptContractRequest, + ): Promise<DecryptContractResponse> { + const res = await decryptContractForMerge( + decodeCrock(req.ciphertext), + decodeCrock(req.pursePub), + decodeCrock(req.contractPriv), + ); + return { + contractTerms: res.contractTerms, + mergePriv: encodeCrock(res.mergePriv), + }; + }, + async signPurseMerge( + tci: TalerCryptoInterfaceR, + req: SignPurseMergeRequest, + ): Promise<SignPurseMergeResponse> { + const mergeSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE) + .put(timestampRoundedToBuffer(req.mergeTimestamp)) + .put(decodeCrock(req.pursePub)) + .put(hashTruncate32(stringToBytes(req.reservePayto + "\0"))) + .build(); + const mergeSigResp = await tci.eddsaSign(tci, { + msg: encodeCrock(mergeSigBlob), + priv: req.mergePriv, + }); + + const reserveSigBlob = buildSigPS( + TalerSignaturePurpose.WALLET_ACCOUNT_MERGE, + ) + .put(timestampRoundedToBuffer(req.purseExpiration)) + .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount))) + .put(amountToBuffer(Amounts.parseOrThrow(req.purseFee))) + .put(decodeCrock(req.contractTermsHash)) + .put(decodeCrock(req.pursePub)) + .put(timestampRoundedToBuffer(req.mergeTimestamp)) + // FIXME: put in min_age + .put(bufferForUint32(0)) + .put(bufferForUint32(req.flags)) + .build(); + + logger.info( + `signing WALLET_ACCOUNT_MERGE over ${encodeCrock(reserveSigBlob)}`, + ); + + const reserveSigResp = await tci.eddsaSign(tci, { + msg: encodeCrock(reserveSigBlob), + priv: req.reservePriv, + }); + + return { + mergeSig: mergeSigResp.sig, + accountSig: reserveSigResp.sig, + }; + }, }; function amountToBuffer(amount: AmountJson): Uint8Array { diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts index 52b96b1a5..6f4a5fa95 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -30,11 +30,16 @@ import { AgeCommitmentProof, AmountJson, + AmountString, CoinEnvelope, DenominationPubKey, + EddsaPublicKeyString, + EddsaSignatureString, ExchangeProtocolVersion, RefreshPlanchetInfo, + TalerProtocolTimestamp, UnblindedSignature, + WalletAccountMergeFlags, } from "@gnu-taler/taler-util"; export interface RefreshNewDenomInfo { @@ -148,4 +153,80 @@ export interface CreateRecoupRefreshReqRequest { denomPub: DenominationPubKey; denomPubHash: string; denomSig: UnblindedSignature; -}
\ No newline at end of file +} + +export interface EncryptedContract { + /** + * Encrypted contract. + */ + econtract: string; + + /** + * Signature over the (encrypted) contract. + */ + econtract_sig: EddsaSignatureString; + + /** + * Ephemeral public key for the DH operation to decrypt the encrypted contract. + */ + contract_pub: EddsaPublicKeyString; +} + +export interface EncryptContractRequest { + contractTerms: any; + + pursePub: string; + pursePriv: string; + + mergePriv: string; +} + +export interface EncryptContractResponse { + econtract: EncryptedContract; + + contractPriv: string; +} + +export interface DecryptContractRequest { + ciphertext: string; + pursePub: string; + contractPriv: string; +} + +export interface DecryptContractResponse { + contractTerms: any; + mergePriv: string; +} + +export interface SignPurseMergeRequest { + mergeTimestamp: TalerProtocolTimestamp; + + pursePub: string; + + reservePayto: string; + + reservePriv: string; + + mergePriv: string; + + purseExpiration: TalerProtocolTimestamp; + + purseAmount: AmountString; + purseFee: AmountString; + + contractTermsHash: string; + + /** + * Flags. + */ + flags: WalletAccountMergeFlags; +} + +export interface SignPurseMergeResponse { + /** + * Signature made by the purse's merge private key. + */ + mergeSig: string; + + accountSig: string; +} diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 8cf5170e5..e4f4ba255 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -42,6 +42,7 @@ import { TalerProtocolDuration, AgeCommitmentProof, PayCoinSelection, + PeerContractTerms, } from "@gnu-taler/taler-util"; import { RetryInfo } from "./util/retries.js"; import { Event, IDBDatabase } from "@gnu-taler/idb-bridge"; @@ -561,6 +562,12 @@ export interface ExchangeRecord { * Retry status for fetching updated information about the exchange. */ retryInfo?: RetryInfo; + + /** + * Public key of the reserve that we're currently using for + * receiving P2P payments. + */ + currentMergeReservePub?: string; } /** @@ -1675,7 +1682,6 @@ export interface BalancePerCurrencyRecord { * Record for a push P2P payment that this wallet initiated. */ export interface PeerPushPaymentInitiationRecord { - /** * What exchange are funds coming from? */ @@ -1704,18 +1710,40 @@ export interface PeerPushPaymentInitiationRecord { */ mergePriv: string; + contractPriv: string; + + contractPub: string; + purseExpiration: TalerProtocolTimestamp; /** * Did we successfully create the purse with the exchange? */ purseCreated: boolean; + + timestampCreated: TalerProtocolTimestamp; } /** - * Record for a push P2P payment that this wallet accepted. + * Record for a push P2P payment that this wallet was offered. + * + * Primary key: (exchangeBaseUrl, pursePub) */ -export interface PeerPushPaymentAcceptanceRecord {} +export interface PeerPushPaymentIncomingRecord { + exchangeBaseUrl: string; + + pursePub: string; + + mergePriv: string; + + contractPriv: string; + + timestampAccepted: TalerProtocolTimestamp; + + contractTerms: PeerContractTerms; + + // FIXME: add status etc. +} export const WalletStoresV1 = { coins: describeStore( @@ -1893,6 +1921,12 @@ export const WalletStoresV1 = { }), {}, ), + peerPushPaymentIncoming: describeStore( + describeContents<PeerPushPaymentIncomingRecord>("peerPushPaymentIncoming", { + keyPath: ["exchangeBaseUrl", "pursePub"], + }), + {}, + ), }; export interface MetaConfigRecord { diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 3a9121502..e4eaf8913 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -16,22 +16,46 @@ import { AmountJson, - Amounts, BackupCoinSourceType, BackupDenomSel, BackupProposalStatus, - BackupPurchase, BackupRefreshReason, BackupRefundState, codecForContractTerms, - DenomKeyType, j2s, Logger, PayCoinSelection, RefreshReason, TalerProtocolTimestamp, - WalletBackupContentV1 + Amounts, + BackupCoinSourceType, + BackupDenomSel, + BackupProposalStatus, + BackupPurchase, + BackupRefreshReason, + BackupRefundState, + codecForContractTerms, + DenomKeyType, + j2s, + Logger, + PayCoinSelection, + RefreshReason, + TalerProtocolTimestamp, + WalletBackupContentV1, } from "@gnu-taler/taler-util"; import { - AbortStatus, CoinSource, + AbortStatus, + CoinSource, CoinSourceType, - CoinStatus, DenominationVerificationStatus, DenomSelectionState, OperationStatus, ProposalDownload, - ProposalStatus, RefreshCoinStatus, RefreshSessionRecord, RefundState, ReserveBankInfo, - ReserveRecordStatus, WalletContractData, WalletRefundItem, WalletStoresV1, WireInfo + CoinStatus, + DenominationVerificationStatus, + DenomSelectionState, + OperationStatus, + ProposalDownload, + ProposalStatus, + RefreshCoinStatus, + RefreshSessionRecord, + RefundState, + ReserveBankInfo, + ReserveRecordStatus, + WalletContractData, + WalletRefundItem, + WalletStoresV1, + WireInfo, } from "../../db.js"; import { InternalWalletState } from "../../internal-wallet-state.js"; import { checkDbInvariant, - checkLogicInvariant + checkLogicInvariant, } from "../../util/invariants.js"; import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js"; import { RetryInfo } from "../../util/retries.js"; @@ -313,14 +337,12 @@ export async function importBackup( } for (const backupDenomination of backupExchangeDetails.denominations) { - if ( - backupDenomination.denom_pub.cipher !== DenomKeyType.Rsa - ) { + if (backupDenomination.denom_pub.cipher !== DenomKeyType.Rsa) { throw Error("unsupported cipher"); } const denomPubHash = cryptoComp.rsaDenomPubToHash[ - backupDenomination.denom_pub.rsa_public_key + backupDenomination.denom_pub.rsa_public_key ]; checkLogicInvariant(!!denomPubHash); const existingDenom = await tx.denominations.get([ @@ -535,7 +557,7 @@ export async function importBackup( const amount = Amounts.parseOrThrow(parsedContractTerms.amount); const contractTermsHash = cryptoComp.proposalIdToContractTermsHash[ - backupProposal.proposal_id + backupProposal.proposal_id ]; let maxWireFee: AmountJson; if (parsedContractTerms.max_wire_fee) { @@ -679,7 +701,7 @@ export async function importBackup( const amount = Amounts.parseOrThrow(parsedContractTerms.amount); const contractTermsHash = cryptoComp.proposalIdToContractTermsHash[ - backupPurchase.proposal_id + backupPurchase.proposal_id ]; let maxWireFee: AmountJson; if (parsedContractTerms.max_wire_fee) { diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index b6bae7518..55b8f513d 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -35,6 +35,7 @@ import { ConfirmPayResult, ConfirmPayResultType, ContractTerms, + ContractTermsUtil, Duration, durationMax, durationMin, @@ -87,7 +88,6 @@ import { selectForcedPayCoins, selectPayCoins, } from "../util/coinSelection.js"; -import { ContractTermsUtil } from "../util/contractTerms.js"; import { getHttpResponseErrorDetails, readSuccessResponseJsonOrErrorCode, diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts b/packages/taler-wallet-core/src/operations/peer-to-peer.ts index e2ae1e66e..658cbe4f7 100644 --- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts +++ b/packages/taler-wallet-core/src/operations/peer-to-peer.ts @@ -18,25 +18,47 @@ * Imports. */ import { + AbsoluteTime, + AcceptPeerPushPaymentRequest, AmountJson, Amounts, - Logger, - InitiatePeerPushPaymentResponse, + AmountString, + buildCodecForObject, + CheckPeerPushPaymentRequest, + CheckPeerPushPaymentResponse, + Codec, + codecForAmountString, + codecForAny, + codecForExchangeGetContractResponse, + ContractTermsUtil, + decodeCrock, + Duration, + eddsaGetPublic, + encodeCrock, + ExchangePurseMergeRequest, InitiatePeerPushPaymentRequest, - strcmp, - CoinPublicKeyString, + InitiatePeerPushPaymentResponse, j2s, - getRandomBytes, - Duration, - durationAdd, + Logger, + strcmp, TalerProtocolTimestamp, - AbsoluteTime, - encodeCrock, - AmountString, UnblindedSignature, + WalletAccountMergeFlags, } from "@gnu-taler/taler-util"; -import { CoinStatus } from "../db.js"; +import { url } from "inspector"; +import { + CoinStatus, + OperationStatus, + ReserveRecord, + ReserveRecordStatus, +} from "../db.js"; +import { + checkSuccessResponseOrThrow, + readSuccessResponseJsonOrThrow, + throwUnexpectedRequestError, +} from "../util/http.js"; import { InternalWalletState } from "../internal-wallet-state.js"; +import { checkDbInvariant } from "../util/invariants.js"; const logger = new Logger("operations/peer-to-peer.ts"); @@ -176,14 +198,22 @@ export async function initiatePeerToPeerPush( const pursePair = await ws.cryptoApi.createEddsaKeypair({}); const mergePair = await ws.cryptoApi.createEddsaKeypair({}); - const hContractTerms = encodeCrock(getRandomBytes(64)); - const purseExpiration = AbsoluteTime.toTimestamp( + + const purseExpiration: TalerProtocolTimestamp = AbsoluteTime.toTimestamp( AbsoluteTime.addDuration( AbsoluteTime.now(), Duration.fromSpec({ days: 2 }), ), ); + const contractTerms = { + ...req.partialContractTerms, + purse_expiration: purseExpiration, + amount: req.amount, + }; + + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + const purseSigResp = await ws.cryptoApi.signPurseCreation({ hContractTerms, mergePub: mergePair.pub, @@ -204,6 +234,13 @@ export async function initiatePeerToPeerPush( coinSelRes.exchangeBaseUrl, ); + const econtractResp = await ws.cryptoApi.encryptContractForMerge({ + contractTerms, + mergePriv: mergePair.priv, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + }); + const httpResp = await ws.http.postJson(createPurseUrl.href, { amount: Amounts.stringify(instructedAmount), merge_pub: mergePair.pub, @@ -212,11 +249,216 @@ export async function initiatePeerToPeerPush( purse_expiration: purseExpiration, deposits: depositSigsResp.deposits, min_age: 0, + econtract: econtractResp.econtract, }); const resp = await httpResp.json(); logger.info(`resp: ${j2s(resp)}`); - throw Error("not yet implemented"); + if (httpResp.status !== 200) { + throw Error("got error response from exchange"); + } + + return { + contractPriv: econtractResp.contractPriv, + mergePriv: mergePair.priv, + pursePub: pursePair.pub, + exchangeBaseUrl: coinSelRes.exchangeBaseUrl, + }; +} + +interface ExchangePurseStatus { + balance: AmountString; +} + +export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> => + buildCodecForObject<ExchangePurseStatus>() + .property("balance", codecForAmountString()) + .build("ExchangePurseStatus"); + +export async function checkPeerPushPayment( + ws: InternalWalletState, + req: CheckPeerPushPaymentRequest, +): Promise<CheckPeerPushPaymentResponse> { + const getPurseUrl = new URL( + `purses/${req.pursePub}/deposit`, + req.exchangeBaseUrl, + ); + + const contractPub = encodeCrock( + eddsaGetPublic(decodeCrock(req.contractPriv)), + ); + + const purseHttpResp = await ws.http.get(getPurseUrl.href); + + const purseStatus = await readSuccessResponseJsonOrThrow( + purseHttpResp, + codecForExchangePurseStatus(), + ); + + const getContractUrl = new URL( + `contracts/${contractPub}`, + req.exchangeBaseUrl, + ); + + const contractHttpResp = await ws.http.get(getContractUrl.href); + + const contractResp = await readSuccessResponseJsonOrThrow( + contractHttpResp, + codecForExchangeGetContractResponse(), + ); + + const dec = await ws.cryptoApi.decryptContractForMerge({ + ciphertext: contractResp.econtract, + contractPriv: req.contractPriv, + pursePub: req.pursePub, + }); + + await ws.db + .mktx((x) => ({ + peerPushPaymentIncoming: x.peerPushPaymentIncoming, + })) + .runReadWrite(async (tx) => { + await tx.peerPushPaymentIncoming.add({ + contractPriv: req.contractPriv, + exchangeBaseUrl: req.exchangeBaseUrl, + mergePriv: dec.mergePriv, + pursePub: req.pursePub, + timestampAccepted: TalerProtocolTimestamp.now(), + contractTerms: dec.contractTerms, + }); + }); + + return { + amount: purseStatus.balance, + contractTerms: dec.contractTerms, + }; +} + +export function talerPaytoFromExchangeReserve( + exchangeBaseUrl: string, + reservePub: string, +): string { + const url = new URL(exchangeBaseUrl); + let proto: string; + if (url.protocol === "http:") { + proto = "taler+http"; + } else if (url.protocol === "https:") { + proto = "taler"; + } else { + throw Error(`unsupported exchange base URL protocol (${url.protocol})`); + } + + let path = url.pathname; + if (!path.endsWith("/")) { + path = path + "/"; + } + + return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; +} + +export async function acceptPeerPushPayment( + ws: InternalWalletState, + req: AcceptPeerPushPaymentRequest, +) { + const peerInc = await ws.db + .mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming })) + .runReadOnly(async (tx) => { + return tx.peerPushPaymentIncoming.get([ + req.exchangeBaseUrl, + req.pursePub, + ]); + }); + + if (!peerInc) { + throw Error("can't accept unknown incoming p2p push payment"); + } + + const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount); + + // We have to create the key pair outside of the transaction, + // due to the async crypto API. + const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); + + const reserve: ReserveRecord | undefined = await ws.db + .mktx((x) => ({ + exchanges: x.exchanges, + reserves: x.reserves, + })) + .runReadWrite(async (tx) => { + const ex = await tx.exchanges.get(req.exchangeBaseUrl); + checkDbInvariant(!!ex); + if (ex.currentMergeReservePub) { + return await tx.reserves.get(ex.currentMergeReservePub); + } + const rec: ReserveRecord = { + exchangeBaseUrl: req.exchangeBaseUrl, + // FIXME: field will be removed in the future, folded into withdrawal/p2p record. + reserveStatus: ReserveRecordStatus.Dormant, + timestampCreated: TalerProtocolTimestamp.now(), + instructedAmount: Amounts.getZero(amount.currency), + currency: amount.currency, + reservePub: newReservePair.pub, + reservePriv: newReservePair.priv, + timestampBankConfirmed: undefined, + timestampReserveInfoPosted: undefined, + // FIXME! + initialDenomSel: undefined as any, + // FIXME! + initialWithdrawalGroupId: "", + initialWithdrawalStarted: false, + lastError: undefined, + operationStatus: OperationStatus.Pending, + retryInfo: undefined, + bankInfo: undefined, + restrictAge: undefined, + senderWire: undefined, + }; + await tx.reserves.put(rec); + return rec; + }); + + if (!reserve) { + throw Error("can't create reserve"); + } + + const mergeTimestamp = TalerProtocolTimestamp.now(); + + const reservePayto = talerPaytoFromExchangeReserve( + reserve.exchangeBaseUrl, + reserve.reservePub, + ); + + const sigRes = await ws.cryptoApi.signPurseMerge({ + contractTermsHash: ContractTermsUtil.hashContractTerms( + peerInc.contractTerms, + ), + flags: WalletAccountMergeFlags.MergeFullyPaidPurse, + mergePriv: peerInc.mergePriv, + mergeTimestamp: mergeTimestamp, + purseAmount: Amounts.stringify(amount), + purseExpiration: peerInc.contractTerms.purse_expiration, + purseFee: Amounts.stringify(Amounts.getZero(amount.currency)), + pursePub: peerInc.pursePub, + reservePayto, + reservePriv: reserve.reservePriv, + }); + + const mergePurseUrl = new URL( + `purses/${req.pursePub}/merge`, + req.exchangeBaseUrl, + ); + + const mergeReq: ExchangePurseMergeRequest = { + payto_uri: reservePayto, + merge_timestamp: mergeTimestamp, + merge_sig: sigRes.mergeSig, + reserve_sig: sigRes.accountSig, + }; + + const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq); + + const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, codecForAny()); + logger.info(`merge result: ${j2s(res)}`); } diff --git a/packages/taler-wallet-core/src/util/contractTerms.test.ts b/packages/taler-wallet-core/src/util/contractTerms.test.ts deleted file mode 100644 index 74cae4ca7..000000000 --- a/packages/taler-wallet-core/src/util/contractTerms.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import test from "ava"; -import { ContractTermsUtil } from "./contractTerms.js"; - -test("contract terms canon hashing", (t) => { - const cReq = { - foo: 42, - bar: "hello", - $forgettable: { - foo: true, - }, - }; - - const c1 = ContractTermsUtil.saltForgettable(cReq); - const c2 = ContractTermsUtil.saltForgettable(cReq); - t.assert(typeof cReq.$forgettable.foo === "boolean"); - t.assert(typeof c1.$forgettable.foo === "string"); - t.assert(c1.$forgettable.foo !== c2.$forgettable.foo); - - const h1 = ContractTermsUtil.hashContractTerms(c1); - - const c3 = ContractTermsUtil.scrub(JSON.parse(JSON.stringify(c1))); - - t.assert(c3.foo === undefined); - t.assert(c3.bar === cReq.bar); - - const h2 = ContractTermsUtil.hashContractTerms(c3); - - t.deepEqual(h1, h2); -}); - -test("contract terms canon hashing (nested)", (t) => { - const cReq = { - foo: 42, - bar: { - prop1: "hello, world", - $forgettable: { - prop1: true, - }, - }, - $forgettable: { - bar: true, - }, - }; - - const c1 = ContractTermsUtil.saltForgettable(cReq); - - t.is(typeof c1.$forgettable.bar, "string"); - t.is(typeof c1.bar.$forgettable.prop1, "string"); - - const forgetPath = (x: any, s: string) => - ContractTermsUtil.forgetAll(x, (p) => p.join(".") === s); - - // Forget bar first - const c2 = forgetPath(c1, "bar"); - - // Forget bar.prop1 first - const c3 = forgetPath(forgetPath(c1, "bar.prop1"), "bar"); - - // Forget everything - const c4 = ContractTermsUtil.scrub(c1); - - const h1 = ContractTermsUtil.hashContractTerms(c1); - const h2 = ContractTermsUtil.hashContractTerms(c2); - const h3 = ContractTermsUtil.hashContractTerms(c3); - const h4 = ContractTermsUtil.hashContractTerms(c4); - - t.is(h1, h2); - t.is(h1, h3); - t.is(h1, h4); - - // Doesn't contain salt - t.false(ContractTermsUtil.validateForgettable(cReq)); - - t.true(ContractTermsUtil.validateForgettable(c1)); - t.true(ContractTermsUtil.validateForgettable(c2)); - t.true(ContractTermsUtil.validateForgettable(c3)); - t.true(ContractTermsUtil.validateForgettable(c4)); -}); - -test("contract terms reference vector", (t) => { - const j = { - k1: 1, - $forgettable: { - k1: "SALT", - }, - k2: { - n1: true, - $forgettable: { - n1: "salt", - }, - }, - k3: { - n1: "string", - }, - }; - - const h = ContractTermsUtil.hashContractTerms(j); - - t.deepEqual( - h, - "VDE8JPX0AEEE3EX1K8E11RYEWSZQKGGZCV6BWTE4ST1C8711P7H850Z7F2Q2HSSYETX87ERC2JNHWB7GTDWTDWMM716VKPSRBXD7SRR", - ); -}); diff --git a/packages/taler-wallet-core/src/util/contractTerms.ts b/packages/taler-wallet-core/src/util/contractTerms.ts deleted file mode 100644 index c2f1ba075..000000000 --- a/packages/taler-wallet-core/src/util/contractTerms.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { canonicalJson, Logger } from "@gnu-taler/taler-util"; -import { kdf } from "@gnu-taler/taler-util"; -import { - decodeCrock, - encodeCrock, - getRandomBytes, - hash, - stringToBytes, -} from "@gnu-taler/taler-util"; - -const logger = new Logger("contractTerms.ts"); - -export namespace ContractTermsUtil { - export type PathPredicate = (path: string[]) => boolean; - - /** - * Scrub all forgettable members from an object. - */ - export function scrub(anyJson: any): any { - return forgetAllImpl(anyJson, [], () => true); - } - - /** - * Recursively forget all forgettable members of an object, - * where the path matches a predicate. - */ - export function forgetAll(anyJson: any, pred: PathPredicate): any { - return forgetAllImpl(anyJson, [], pred); - } - - function forgetAllImpl( - anyJson: any, - path: string[], - pred: PathPredicate, - ): any { - const dup = JSON.parse(JSON.stringify(anyJson)); - if (Array.isArray(dup)) { - for (let i = 0; i < dup.length; i++) { - dup[i] = forgetAllImpl(dup[i], [...path, `${i}`], pred); - } - } else if (typeof dup === "object" && dup != null) { - if (typeof dup.$forgettable === "object") { - for (const x of Object.keys(dup.$forgettable)) { - if (!pred([...path, x])) { - continue; - } - if (!dup.$forgotten) { - dup.$forgotten = {}; - } - if (!dup.$forgotten[x]) { - const membValCanon = stringToBytes( - canonicalJson(scrub(dup[x])) + "\0", - ); - const membSalt = stringToBytes(dup.$forgettable[x] + "\0"); - const h = kdf(64, membValCanon, membSalt, new Uint8Array([])); - dup.$forgotten[x] = encodeCrock(h); - } - delete dup[x]; - delete dup.$forgettable[x]; - } - if (Object.keys(dup.$forgettable).length === 0) { - delete dup.$forgettable; - } - } - for (const x of Object.keys(dup)) { - if (x.startsWith("$")) { - continue; - } - dup[x] = forgetAllImpl(dup[x], [...path, x], pred); - } - } - return dup; - } - - /** - * Generate a salt for all members marked as forgettable, - * but which don't have an actual salt yet. - */ - export function saltForgettable(anyJson: any): any { - const dup = JSON.parse(JSON.stringify(anyJson)); - if (Array.isArray(dup)) { - for (let i = 0; i < dup.length; i++) { - dup[i] = saltForgettable(dup[i]); - } - } else if (typeof dup === "object" && dup !== null) { - if (typeof dup.$forgettable === "object") { - for (const k of Object.keys(dup.$forgettable)) { - if (dup.$forgettable[k] === true) { - dup.$forgettable[k] = encodeCrock(getRandomBytes(32)); - } - } - } - for (const x of Object.keys(dup)) { - if (x.startsWith("$")) { - continue; - } - dup[x] = saltForgettable(dup[x]); - } - } - return dup; - } - - const nameRegex = /^[0-9A-Za-z_]+$/; - - /** - * Check that the given JSON object is well-formed with regards - * to forgettable fields and other restrictions for forgettable JSON. - */ - export function validateForgettable(anyJson: any): boolean { - if (typeof anyJson === "string") { - return true; - } - if (typeof anyJson === "number") { - return ( - Number.isInteger(anyJson) && - anyJson >= Number.MIN_SAFE_INTEGER && - anyJson <= Number.MAX_SAFE_INTEGER - ); - } - if (typeof anyJson === "boolean") { - return true; - } - if (anyJson === null) { - return true; - } - if (Array.isArray(anyJson)) { - return anyJson.every((x) => validateForgettable(x)); - } - if (typeof anyJson === "object") { - for (const k of Object.keys(anyJson)) { - if (k.match(nameRegex)) { - if (validateForgettable(anyJson[k])) { - continue; - } else { - return false; - } - } - if (k === "$forgettable") { - const fga = anyJson.$forgettable; - if (!fga || typeof fga !== "object") { - return false; - } - for (const fk of Object.keys(fga)) { - if (!fk.match(nameRegex)) { - return false; - } - if (!(fk in anyJson)) { - return false; - } - const fv = anyJson.$forgettable[fk]; - if (typeof fv !== "string") { - return false; - } - } - } else if (k === "$forgotten") { - const fgo = anyJson.$forgotten; - if (!fgo || typeof fgo !== "object") { - return false; - } - for (const fk of Object.keys(fgo)) { - if (!fk.match(nameRegex)) { - return false; - } - // Check that the value has actually been forgotten. - if (fk in anyJson) { - return false; - } - const fv = anyJson.$forgotten[fk]; - if (typeof fv !== "string") { - return false; - } - try { - const decFv = decodeCrock(fv); - if (decFv.length != 64) { - return false; - } - } catch (e) { - return false; - } - // Check that salt has been deleted after forgetting. - if (anyJson.$forgettable?.[k] !== undefined) { - return false; - } - } - } else { - return false; - } - } - return true; - } - return false; - } - - /** - * Check that no forgettable information has been forgotten. - * - * Must only be called on an object already validated with validateForgettable. - */ - export function validateNothingForgotten(contractTerms: any): boolean { - throw Error("not implemented yet"); - } - - /** - * Hash a contract terms object. Forgettable fields - * are scrubbed and JSON canonicalization is applied - * before hashing. - */ - export function hashContractTerms(contractTerms: unknown): string { - const cleaned = scrub(contractTerms); - const canon = canonicalJson(cleaned) + "\0"; - const bytes = stringToBytes(canon); - return encodeCrock(hash(bytes)); - } -} diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 5c0882ae0..cc9e98f8c 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -27,6 +27,7 @@ import { AcceptExchangeTosRequest, AcceptManualWithdrawalRequest, AcceptManualWithdrawalResult, + AcceptPeerPushPaymentRequest, AcceptTipRequest, AcceptWithdrawalResponse, AddExchangeRequest, @@ -34,6 +35,8 @@ import { ApplyRefundResponse, BackupRecovery, BalancesResponse, + CheckPeerPushPaymentRequest, + CheckPeerPushPaymentResponse, CoinDumpJson, ConfirmPayRequest, ConfirmPayResult, @@ -286,6 +289,14 @@ export type WalletOperations = { request: InitiatePeerPushPaymentRequest; response: InitiatePeerPushPaymentResponse; }; + [WalletApiOperation.CheckPeerPushPayment]: { + request: CheckPeerPushPaymentRequest; + response: CheckPeerPushPaymentResponse; + }; + [WalletApiOperation.AcceptPeerPushPayment]: { + request: AcceptPeerPushPaymentRequest; + response: {}; + }; }; export type RequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index d072f9e96..b56e9402d 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -32,11 +32,13 @@ import { codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, codecForAcceptManualWithdrawalRequet, + codecForAcceptPeerPushPaymentRequest, codecForAcceptTipRequest, codecForAddExchangeRequest, codecForAny, codecForApplyRefundFromPurchaseIdRequest, codecForApplyRefundRequest, + codecForCheckPeerPushPaymentRequest, codecForConfirmPayRequest, codecForCreateDepositGroupRequest, codecForDeleteTransactionRequest, @@ -144,7 +146,11 @@ import { processDownloadProposal, processPurchasePay, } from "./operations/pay.js"; -import { initiatePeerToPeerPush } from "./operations/peer-to-peer.js"; +import { + acceptPeerPushPayment, + checkPeerPushPayment, + initiatePeerToPeerPush, +} from "./operations/peer-to-peer.js"; import { getPendingOperations } from "./operations/pending.js"; import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js"; import { @@ -1055,6 +1061,15 @@ async function dispatchRequestInternal( const req = codecForInitiatePeerPushPaymentRequest().decode(payload); return await initiatePeerToPeerPush(ws, req); } + case "checkPeerPushPayment": { + const req = codecForCheckPeerPushPaymentRequest().decode(payload); + return await checkPeerPushPayment(ws, req); + } + case "acceptPeerPushPayment": { + const req = codecForAcceptPeerPushPaymentRequest().decode(payload); + await acceptPeerPushPayment(ws, req); + return {}; + } } throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, |