diff options
author | Florian Dold <florian@dold.me> | 2022-08-23 11:29:45 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2022-08-23 20:35:11 +0200 |
commit | f3ff5a72257dda27cab555f8b8d921d45bfc3e4b (patch) | |
tree | 708494a3aa2346fead51931fe4bfc41cc0249659 | |
parent | 4ca38113abee2c0ca17b26aa55cf6a0ecafe49c9 (diff) |
peer-to-peer pull payments MVP
p2p pull wip
-rw-r--r-- | packages/taler-util/src/talerCrypto.ts | 51 | ||||
-rw-r--r-- | packages/taler-util/src/talerTypes.ts | 71 | ||||
-rw-r--r-- | packages/taler-util/src/taleruri.ts | 66 | ||||
-rw-r--r-- | packages/taler-util/src/walletTypes.ts | 81 | ||||
-rw-r--r-- | packages/taler-wallet-cli/src/harness/harness.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer-pull.ts | 70 | ||||
-rw-r--r-- | packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer-push.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts) | 4 | ||||
-rw-r--r-- | packages/taler-wallet-cli/src/integrationtests/testrunner.ts | 6 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 140 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/crypto/cryptoTypes.ts | 66 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 65 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/peer-to-peer.ts | 164 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/wallet-api-types.ts | 20 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/wallet.ts | 27 |
14 files changed, 759 insertions, 74 deletions
diff --git a/packages/taler-util/src/talerCrypto.ts b/packages/taler-util/src/talerCrypto.ts index 38bb5ad0a..d7734707a 100644 --- a/packages/taler-util/src/talerCrypto.ts +++ b/packages/taler-util/src/talerCrypto.ts @@ -1214,6 +1214,9 @@ type ContractPrivateKey = FlavorP<Uint8Array, "ContractPrivateKey", 32> & type MergePrivateKey = FlavorP<Uint8Array, "MergePrivateKey", 32> & MaterialEddsaPriv; +const mergeSalt = "p2p-merge-contract"; +const depositSalt = "p2p-deposit-contract"; + export function encryptContractForMerge( pursePub: PursePublicKey, contractPriv: ContractPrivateKey, @@ -1230,12 +1233,24 @@ export function encryptContractForMerge( contractTermsCompressed, ]); const key = keyExchangeEcdheEddsa(contractPriv, pursePub); - return encryptWithDerivedKey( - getRandomBytesF(24), - key, - data, - "p2p-merge-contract", - ); + return encryptWithDerivedKey(getRandomBytesF(24), key, data, mergeSalt); +} + +export function encryptContractForDeposit( + pursePub: PursePublicKey, + contractPriv: ContractPrivateKey, + contractTerms: any, +): Promise<OpaqueData> { + const contractTermsCanon = canonicalJson(contractTerms) + "\0"; + const contractTermsBytes = stringToBytes(contractTermsCanon); + const contractTermsCompressed = fflate.zlibSync(contractTermsBytes); + const data = typedArrayConcat([ + bufferForUint32(ContractFormatTag.PaymentRequest), + bufferForUint32(contractTermsBytes.length), + contractTermsCompressed, + ]); + const key = keyExchangeEcdheEddsa(contractPriv, pursePub); + return encryptWithDerivedKey(getRandomBytesF(24), key, data, depositSalt); } export interface DecryptForMergeResult { @@ -1243,13 +1258,17 @@ export interface DecryptForMergeResult { mergePriv: Uint8Array; } +export interface DecryptForDepositResult { + contractTerms: any; +} + export async function decryptContractForMerge( enc: OpaqueData, pursePub: PursePublicKey, contractPriv: ContractPrivateKey, ): Promise<DecryptForMergeResult> { const key = keyExchangeEcdheEddsa(contractPriv, pursePub); - const dec = await decryptWithDerivedKey(enc, key, "p2p-merge-contract"); + const dec = await decryptWithDerivedKey(enc, key, mergeSalt); const mergePriv = dec.slice(8, 8 + 32); const contractTermsCompressed = dec.slice(8 + 32); const contractTermsBuf = fflate.unzlibSync(contractTermsCompressed); @@ -1263,6 +1282,20 @@ export async function decryptContractForMerge( }; } -export function encryptContractForDeposit() { - throw Error("not implemented"); +export async function decryptContractForDeposit( + enc: OpaqueData, + pursePub: PursePublicKey, + contractPriv: ContractPrivateKey, +): Promise<DecryptForDepositResult> { + const key = keyExchangeEcdheEddsa(contractPriv, pursePub); + const dec = await decryptWithDerivedKey(enc, key, depositSalt); + const contractTermsCompressed = dec.slice(8); + const contractTermsBuf = fflate.unzlibSync(contractTermsCompressed); + // Slice of the '\0' at the end and decode to a string + const contractTermsString = bytesToString( + contractTermsBuf.slice(0, contractTermsBuf.length - 1), + ); + return { + contractTerms: JSON.parse(contractTermsString), + }; } diff --git a/packages/taler-util/src/talerTypes.ts b/packages/taler-util/src/talerTypes.ts index d4de8c37b..ee2dee93c 100644 --- a/packages/taler-util/src/talerTypes.ts +++ b/packages/taler-util/src/talerTypes.ts @@ -1874,3 +1874,74 @@ export interface PeerContractTerms { summary: string; purse_expiration: TalerProtocolTimestamp; } + +export interface EncryptedContract { + // Encrypted contract. + econtract: string; + + // Signature over the (encrypted) contract. + econtract_sig: string; + + // Ephemeral public key for the DH operation to decrypt the encrypted contract. + contract_pub: string; +} + +/** + * Payload for /reserves/{reserve_pub}/purse + * endpoint of the exchange. + */ +export interface ExchangeReservePurseRequest { + /** + * Minimum amount that must be credited to the reserve, that is + * the total value of the purse minus the deposit fees. + * If the deposit fees are lower, the contribution to the + * reserve can be higher! + */ + purse_value: AmountString; + + // Minimum age required for all coins deposited into the purse. + min_age: number; + + // Purse fee the reserve owner is willing to pay + // for the purse creation. Optional, if not present + // the purse is to be created from the purse quota + // of the reserve. + purse_fee: AmountString; + + // Optional encrypted contract, in case the buyer is + // proposing the contract and thus establishing the + // purse with the payment. + econtract?: EncryptedContract; + + // EdDSA public key used to approve merges of this purse. + merge_pub: EddsaPublicKeyString; + + // EdDSA signature of the purse private key affirming the merge + // over a TALER_PurseMergeSignaturePS. + // Must be of purpose TALER_SIGNATURE_PURSE_MERGE. + merge_sig: EddsaSignatureString; + + // EdDSA signature of the account/reserve affirming the merge. + // Must be of purpose TALER_SIGNATURE_WALLET_ACCOUNT_MERGE + reserve_sig: EddsaSignatureString; + + // Purse public key. + purse_pub: EddsaPublicKeyString; + + // EdDSA signature of the purse over + // TALER_PurseRequestSignaturePS of + // purpose TALER_SIGNATURE_PURSE_REQUEST + // confirming that the + // above details hold for this purse. + purse_sig: EddsaSignatureString; + + // SHA-512 hash of the contact of the purse. + h_contract_terms: HashCodeString; + + // Client-side timestamp of when the merge request was made. + merge_timestamp: TalerProtocolTimestamp; + + // Indicative time by which the purse should expire + // if it has not been paid. + purse_expiration: TalerProtocolTimestamp; +} diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts index e3bd120f0..e7d66d7d5 100644 --- a/packages/taler-util/src/taleruri.ts +++ b/packages/taler-util/src/taleruri.ts @@ -45,6 +45,11 @@ export interface PayPushUriResult { contractPriv: string; } +export interface PayPullUriResult { + exchangeBaseUrl: string; + contractPriv: string; +} + /** * Parse a taler[+http]://withdraw URI. * Return undefined if not passed a valid URI. @@ -84,10 +89,14 @@ export enum TalerUriType { TalerTip = "taler-tip", TalerRefund = "taler-refund", TalerNotifyReserve = "taler-notify-reserve", - TalerPayPush = "pay-push", + TalerPayPush = "taler-pay-push", + TalerPayPull = "taler-pay-pull", Unknown = "unknown", } +const talerActionPayPull = "pay-pull"; +const talerActionPayPush = "pay-push"; + /** * Classify a taler:// URI. */ @@ -117,12 +126,18 @@ export function classifyTalerUri(s: string): TalerUriType { if (sl.startsWith("taler+http://withdraw/")) { return TalerUriType.TalerWithdraw; } - if (sl.startsWith("taler://pay-push/")) { + if (sl.startsWith(`taler://${talerActionPayPush}/`)) { return TalerUriType.TalerPayPush; } - if (sl.startsWith("taler+http://pay-push/")) { + if (sl.startsWith(`taler+http://${talerActionPayPush}/`)) { return TalerUriType.TalerPayPush; } + if (sl.startsWith(`taler://${talerActionPayPull}/`)) { + return TalerUriType.TalerPayPull; + } + if (sl.startsWith(`taler+http://${talerActionPayPull}/`)) { + return TalerUriType.TalerPayPull; + } if (sl.startsWith("taler://notify-reserve/")) { return TalerUriType.TalerNotifyReserve; } @@ -189,7 +204,29 @@ export function parsePayUri(s: string): PayUriResult | undefined { } export function parsePayPushUri(s: string): PayPushUriResult | undefined { - const pi = parseProtoInfo(s, "pay-push"); + const pi = parseProtoInfo(s, talerActionPayPush); + if (!pi) { + return undefined; + } + const c = pi?.rest.split("?"); + const parts = c[0].split("/"); + if (parts.length < 2) { + return undefined; + } + const host = parts[0].toLowerCase(); + const contractPriv = parts[parts.length - 1]; + const pathSegments = parts.slice(1, parts.length - 1); + const p = [host, ...pathSegments].join("/"); + const exchangeBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`); + + return { + exchangeBaseUrl, + contractPriv, + }; +} + +export function parsePayPullUri(s: string): PayPullUriResult | undefined { + const pi = parseProtoInfo(s, talerActionPayPull); if (!pi) { return undefined; } @@ -283,3 +320,24 @@ export function constructPayPushUri(args: { } return `${proto}://pay-push/${url.host}${url.pathname}${args.contractPriv}`; } + +export function constructPayPullUri(args: { + exchangeBaseUrl: string; + contractPriv: string; +}): string { + const url = new URL(args.exchangeBaseUrl); + let proto: string; + if (url.protocol === "https:") { + proto = "taler"; + } else if (url.protocol === "http:") { + proto = "taler+http"; + } else { + throw Error(`Unsupported exchange URL protocol ${args.exchangeBaseUrl}`); + } + if (!url.pathname.endsWith("/")) { + throw Error( + `exchange base URL must end with a slash (got ${args.exchangeBaseUrl}instead)`, + ); + } + return `${proto}://pay-pull/${url.host}${url.pathname}${args.contractPriv}`; +} diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index 7b482c60e..3a415b221 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -627,7 +627,7 @@ export interface ExchangeAccount { master_sig: string; } -export type WireFeeMap = { [wireMethod: string]: WireFee[] } +export type WireFeeMap = { [wireMethod: string]: WireFee[] }; export interface WireInfo { feesForType: WireFeeMap; accounts: ExchangeAccount[]; @@ -639,7 +639,6 @@ const codecForExchangeAccount = (): Codec<ExchangeAccount> => .property("master_sig", codecForString()) .build("codecForExchangeAccount"); - const codecForWireFee = (): Codec<WireFee> => buildCodecForObject<WireFee>() .property("sig", codecForString()) @@ -658,19 +657,18 @@ const codecForWireInfo = (): Codec<WireInfo> => const codecForDenominationInfo = (): Codec<DenominationInfo> => buildCodecForObject<DenominationInfo>() - .property("denomPubHash", (codecForString())) - .property("value", (codecForAmountJson())) - .property("feeWithdraw", (codecForAmountJson())) - .property("feeDeposit", (codecForAmountJson())) - .property("feeRefresh", (codecForAmountJson())) - .property("feeRefund", (codecForAmountJson())) - .property("stampStart", (codecForTimestamp)) - .property("stampExpireWithdraw", (codecForTimestamp)) - .property("stampExpireLegal", (codecForTimestamp)) - .property("stampExpireDeposit", (codecForTimestamp)) + .property("denomPubHash", codecForString()) + .property("value", codecForAmountJson()) + .property("feeWithdraw", codecForAmountJson()) + .property("feeDeposit", codecForAmountJson()) + .property("feeRefresh", codecForAmountJson()) + .property("feeRefund", codecForAmountJson()) + .property("stampStart", codecForTimestamp) + .property("stampExpireWithdraw", codecForTimestamp) + .property("stampExpireLegal", codecForTimestamp) + .property("stampExpireDeposit", codecForTimestamp) .build("codecForDenominationInfo"); - export interface DenominationInfo { value: AmountJson; denomPubHash: string; @@ -713,7 +711,6 @@ export interface DenominationInfo { * Data after which coins of this denomination can't be deposited anymore. */ stampExpireDeposit: TalerProtocolTimestamp; - } export interface ExchangeListItem { @@ -726,7 +723,6 @@ export interface ExchangeListItem { denominations: DenominationInfo[]; } - const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> => buildCodecForObject<AuditorDenomSig>() .property("denom_pub_h", codecForString()) @@ -740,7 +736,6 @@ const codecForExchangeAuditor = (): Codec<ExchangeAuditor> => .property("denomination_keys", codecForList(codecForAuditorDenomSig())) .build("codecForExchangeAuditor"); - const codecForExchangeTos = (): Codec<ExchangeTos> => buildCodecForObject<ExchangeTos>() .property("acceptedVersion", codecOptional(codecForString())) @@ -1452,18 +1447,34 @@ export interface CheckPeerPushPaymentRequest { talerUri: string; } +export interface CheckPeerPullPaymentRequest { + talerUri: string; +} + export interface CheckPeerPushPaymentResponse { contractTerms: any; amount: AmountString; peerPushPaymentIncomingId: string; } +export interface CheckPeerPullPaymentResponse { + contractTerms: any; + amount: AmountString; + peerPullPaymentIncomingId: string; +} + export const codecForCheckPeerPushPaymentRequest = (): Codec<CheckPeerPushPaymentRequest> => buildCodecForObject<CheckPeerPushPaymentRequest>() .property("talerUri", codecForString()) .build("CheckPeerPushPaymentRequest"); +export const codecForCheckPeerPullPaymentRequest = + (): Codec<CheckPeerPullPaymentRequest> => + buildCodecForObject<CheckPeerPullPaymentRequest>() + .property("talerUri", codecForString()) + .build("CheckPeerPullPaymentRequest"); + export interface AcceptPeerPushPaymentRequest { /** * Transparent identifier of the incoming peer push payment. @@ -1476,3 +1487,41 @@ export const codecForAcceptPeerPushPaymentRequest = buildCodecForObject<AcceptPeerPushPaymentRequest>() .property("peerPushPaymentIncomingId", codecForString()) .build("AcceptPeerPushPaymentRequest"); + +export interface AcceptPeerPullPaymentRequest { + /** + * Transparent identifier of the incoming peer pull payment. + */ + peerPullPaymentIncomingId: string; +} + +export const codecForAcceptPeerPullPaymentRequest = + (): Codec<AcceptPeerPullPaymentRequest> => + buildCodecForObject<AcceptPeerPullPaymentRequest>() + .property("peerPullPaymentIncomingId", codecForString()) + .build("AcceptPeerPllPaymentRequest"); + +export interface InitiatePeerPullPaymentRequest { + /** + * FIXME: Make this optional? + */ + exchangeBaseUrl: string; + amount: AmountString; + partialContractTerms: any; +} + +export const codecForInitiatePeerPullPaymentRequest = + (): Codec<InitiatePeerPullPaymentRequest> => + buildCodecForObject<InitiatePeerPullPaymentRequest>() + .property("partialContractTerms", codecForAny()) + .property("amount", codecForAmountString()) + .property("exchangeBaseUrl", codecForAmountString()) + .build("InitiatePeerPullPaymentRequest"); + +export interface InitiatePeerPullPaymentResponse { + /** + * Taler URI for the other party to make the payment + * that was requested. + */ + talerUri: string; +} diff --git a/packages/taler-wallet-cli/src/harness/harness.ts b/packages/taler-wallet-cli/src/harness/harness.ts index c735c9956..33f677d94 100644 --- a/packages/taler-wallet-cli/src/harness/harness.ts +++ b/packages/taler-wallet-cli/src/harness/harness.ts @@ -1284,7 +1284,7 @@ export class ExchangeService implements ExchangeServiceInterface { // account fee `${this.exchangeConfig.currency}:0.01`, // purse fee - `${this.exchangeConfig.currency}:0.01`, + `${this.exchangeConfig.currency}:0.00`, // purse timeout "1h", // kyc timeout diff --git a/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer-pull.ts b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer-pull.ts new file mode 100644 index 000000000..e78bd5a29 --- /dev/null +++ b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer-pull.ts @@ -0,0 +1,70 @@ +/* + This file is part of GNU Taler + (C) 2020 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 { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { GlobalTestState } from "../harness/harness.js"; +import { + createSimpleTestkudosEnvironment, + withdrawViaBank, +} from "../harness/helpers.js"; + +/** + * Run test for basic, bank-integrated withdrawal and payment. + */ +export async function runPeerToPeerPullTest(t: GlobalTestState) { + // Set up test environment + + const { wallet, bank, exchange, merchant } = + await createSimpleTestkudosEnvironment(t); + + // Withdraw digital cash into the wallet. + + await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" }); + + await wallet.runUntilDone(); + + const resp = await wallet.client.call( + WalletApiOperation.InitiatePeerPullPayment, + { + exchangeBaseUrl: exchange.baseUrl, + amount: "TESTKUDOS:5", + partialContractTerms: { + summary: "Hello World", + }, + }, + ); + + const checkResp = await wallet.client.call( + WalletApiOperation.CheckPeerPullPayment, + { + talerUri: resp.talerUri, + }, + ); + + const acceptResp = await wallet.client.call( + WalletApiOperation.AcceptPeerPullPayment, + { + peerPullPaymentIncomingId: checkResp.peerPullPaymentIncomingId, + }, + ); + + await wallet.runUntilDone(); +} + +runPeerToPeerPullTest.suites = ["wallet"]; diff --git a/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer-push.ts index c22258bc8..11360f6e9 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer-push.ts @@ -27,7 +27,7 @@ import { /** * Run test for basic, bank-integrated withdrawal and payment. */ -export async function runPeerToPeerTest(t: GlobalTestState) { +export async function runPeerToPeerPushTest(t: GlobalTestState) { // Set up test environment const { wallet, bank, exchange, merchant } = @@ -72,4 +72,4 @@ export async function runPeerToPeerTest(t: GlobalTestState) { await wallet.runUntilDone(); } -runPeerToPeerTest.suites = ["wallet"]; +runPeerToPeerPushTest.suites = ["wallet"]; diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts index cafcce79e..88e67a8bb 100644 --- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts +++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts @@ -73,7 +73,8 @@ import { runPaymentDemoTest } from "./test-payment-on-demo"; import { runPaymentTransientTest } from "./test-payment-transient"; import { runPaymentZeroTest } from "./test-payment-zero.js"; import { runPaywallFlowTest } from "./test-paywall-flow"; -import { runPeerToPeerTest } from "./test-peer-to-peer.js"; +import { runPeerToPeerPullTest } from "./test-peer-to-peer-pull.js"; +import { runPeerToPeerPushTest } from "./test-peer-to-peer-push.js"; import { runRefundTest } from "./test-refund"; import { runRefundAutoTest } from "./test-refund-auto"; import { runRefundGoneTest } from "./test-refund-gone"; @@ -154,7 +155,8 @@ const allTests: TestMainFunction[] = [ runPaymentZeroTest, runPayPaidTest, runPaywallFlowTest, - runPeerToPeerTest, + runPeerToPeerPushTest, + runPeerToPeerPullTest, runRefundAutoTest, runRefundGoneTest, runRefundIncrementalTest, diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts index 099bf09fe..2f39f7806 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -33,11 +33,11 @@ import { BlindedDenominationSignature, bufferForUint32, buildSigPS, - bytesToString, CoinDepositPermission, CoinEnvelope, createHashContext, decodeCrock, + decryptContractForDeposit, decryptContractForMerge, DenomKeyType, DepositInfo, @@ -47,6 +47,7 @@ import { eddsaSign, eddsaVerify, encodeCrock, + encryptContractForDeposit, encryptContractForMerge, ExchangeProtocolVersion, getRandomBytes, @@ -85,17 +86,23 @@ import { DenominationRecord } from "../db.js"; import { CreateRecoupRefreshReqRequest, CreateRecoupReqRequest, + DecryptContractForDepositRequest, + DecryptContractForDepositResponse, DecryptContractRequest, DecryptContractResponse, DerivedRefreshSession, DerivedTipPlanchet, DeriveRefreshSessionRequest, DeriveTipRequest, + EncryptContractForDepositRequest, + EncryptContractForDepositResponse, EncryptContractRequest, EncryptContractResponse, EncryptedContract, SignPurseMergeRequest, SignPurseMergeResponse, + SignReservePurseCreateRequest, + SignReservePurseCreateResponse, SignTrackTransactionRequest, } from "./cryptoTypes.js"; @@ -205,7 +212,19 @@ export interface TalerCryptoInterface { req: DecryptContractRequest, ): Promise<DecryptContractResponse>; + encryptContractForDeposit( + req: EncryptContractForDepositRequest, + ): Promise<EncryptContractForDepositResponse>; + + decryptContractForDeposit( + req: DecryptContractForDepositRequest, + ): Promise<DecryptContractForDepositResponse>; + signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>; + + signReservePurseCreate( + req: SignReservePurseCreateRequest, + ): Promise<SignReservePurseCreateResponse>; } /** @@ -362,6 +381,21 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<SignPurseMergeResponse> { throw new Error("Function not implemented."); }, + encryptContractForDeposit: function ( + req: EncryptContractForDepositRequest, + ): Promise<EncryptContractForDepositResponse> { + throw new Error("Function not implemented."); + }, + decryptContractForDeposit: function ( + req: DecryptContractForDepositRequest, + ): Promise<DecryptContractForDepositResponse> { + throw new Error("Function not implemented."); + }, + signReservePurseCreate: function ( + req: SignReservePurseCreateRequest, + ): Promise<SignReservePurseCreateResponse> { + throw new Error("Function not implemented."); + }, }; export type WithArg<X> = X extends (req: infer T) => infer R @@ -1047,7 +1081,8 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { if (depositInfo.requiredMinimumAge != null) { s.minimum_age_sig = minimumAgeSig; - s.age_commitment = depositInfo.ageCommitmentProof?.commitment.publicKeys; + s.age_commitment = + depositInfo.ageCommitmentProof?.commitment.publicKeys; } else if (depositInfo.ageCommitmentProof) { (s as any).h_age_commitment = hAgeCommitment; } @@ -1389,6 +1424,43 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { mergePriv: encodeCrock(res.mergePriv), }; }, + async encryptContractForDeposit( + tci: TalerCryptoInterfaceR, + req: EncryptContractForDepositRequest, + ): Promise<EncryptContractForDepositResponse> { + const contractKeyPair = await this.createEddsaKeypair(tci, {}); + const enc = await encryptContractForDeposit( + decodeCrock(req.pursePub), + decodeCrock(contractKeyPair.priv), + 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 decryptContractForDeposit( + tci: TalerCryptoInterfaceR, + req: DecryptContractForDepositRequest, + ): Promise<DecryptContractForDepositResponse> { + const res = await decryptContractForDeposit( + decodeCrock(req.ciphertext), + decodeCrock(req.pursePub), + decodeCrock(req.contractPriv), + ); + return { + contractTerms: res.contractTerms, + }; + }, async signPurseMerge( tci: TalerCryptoInterfaceR, req: SignPurseMergeRequest, @@ -1431,6 +1503,70 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { accountSig: reserveSigResp.sig, }; }, + async signReservePurseCreate( + tci: TalerCryptoInterfaceR, + req: SignReservePurseCreateRequest, + ): Promise<SignReservePurseCreateResponse> { + 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, + }); + + logger.info(`payto URI: ${req.reservePayto}`); + logger.info( + `signing WALLET_PURSE_MERGE over ${encodeCrock(mergeSigBlob)}`, + ); + + 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, + }); + + const mergePub = encodeCrock(eddsaGetPublic(decodeCrock(req.mergePriv))); + + const purseSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_CREATE) + .put(timestampRoundedToBuffer(req.purseExpiration)) + .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount))) + .put(decodeCrock(req.contractTermsHash)) + .put(decodeCrock(mergePub)) + // FIXME: add age! + .put(bufferForUint32(0)) + .build(); + + const purseSigResp = await tci.eddsaSign(tci, { + msg: encodeCrock(purseSigBlob), + priv: req.pursePriv, + }); + + return { + mergeSig: mergeSigResp.sig, + accountSig: reserveSigResp.sig, + purseSig: purseSigResp.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 6f4a5fa95..6e0e01627 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -187,6 +187,19 @@ export interface EncryptContractResponse { contractPriv: string; } +export interface EncryptContractForDepositRequest { + contractTerms: any; + + pursePub: string; + pursePriv: string; +} + +export interface EncryptContractForDepositResponse { + econtract: EncryptedContract; + + contractPriv: string; +} + export interface DecryptContractRequest { ciphertext: string; pursePub: string; @@ -198,6 +211,16 @@ export interface DecryptContractResponse { mergePriv: string; } +export interface DecryptContractForDepositRequest { + ciphertext: string; + pursePub: string; + contractPriv: string; +} + +export interface DecryptContractForDepositResponse { + contractTerms: any; +} + export interface SignPurseMergeRequest { mergeTimestamp: TalerProtocolTimestamp; @@ -227,6 +250,47 @@ export interface SignPurseMergeResponse { * Signature made by the purse's merge private key. */ mergeSig: string; - + + accountSig: string; +} + +export interface SignReservePurseCreateRequest { + mergeTimestamp: TalerProtocolTimestamp; + + pursePub: string; + + pursePriv: string; + + reservePayto: string; + + reservePriv: string; + + mergePriv: string; + + purseExpiration: TalerProtocolTimestamp; + + purseAmount: AmountString; + purseFee: AmountString; + + contractTermsHash: string; + + /** + * Flags. + */ + flags: WalletAccountMergeFlags; +} + +/** + * Response with signatures needed for creation of a purse + * from a reserve for a PULL payment. + */ +export interface SignReservePurseCreateResponse { + /** + * Signature made by the purse's merge private key. + */ + mergeSig: string; + accountSig: string; + + purseSig: string; } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index a34a09f75..266197eb5 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -393,7 +393,6 @@ export interface ExchangeDetailsRecord { wireInfo: WireInfo; } - export interface ExchangeDetailsPointer { masterPublicKey: string; @@ -922,7 +921,6 @@ export interface RefreshSessionRecord { norevealIndex?: number; } - export enum RefundState { Failed = "failed", Applied = "applied", @@ -1186,9 +1184,9 @@ export const WALLET_BACKUP_STATE_KEY = "walletBackupState"; */ export type ConfigRecord = | { - key: typeof WALLET_BACKUP_STATE_KEY; - value: WalletBackupConfState; - } + key: typeof WALLET_BACKUP_STATE_KEY; + value: WalletBackupConfState; + } | { key: "currencyDefaultsApplied"; value: boolean }; export interface WalletBackupConfState { @@ -1405,17 +1403,17 @@ export enum BackupProviderStateTag { export type BackupProviderState = | { - tag: BackupProviderStateTag.Provisional; - } + tag: BackupProviderStateTag.Provisional; + } | { - tag: BackupProviderStateTag.Ready; - nextBackupTimestamp: TalerProtocolTimestamp; - } + tag: BackupProviderStateTag.Ready; + nextBackupTimestamp: TalerProtocolTimestamp; + } | { - tag: BackupProviderStateTag.Retrying; - retryInfo: RetryInfo; - lastError?: TalerErrorDetail; - }; + tag: BackupProviderStateTag.Retrying; + retryInfo: RetryInfo; + lastError?: TalerErrorDetail; + }; export interface BackupProviderTerms { supportedProtocolVersion: string; @@ -1625,6 +1623,36 @@ export interface PeerPushPaymentInitiationRecord { timestampCreated: TalerProtocolTimestamp; } +export interface PeerPullPaymentInitiationRecord { + /** + * What exchange are we using for the payment request? + */ + exchangeBaseUrl: string; + + /** + * Amount requested. + */ + amount: AmountString; + + /** + * Purse public key. Used as the primary key to look + * up this record. + */ + pursePub: string; + + /** + * Purse private key. + */ + pursePriv: string; + + /** + * Contract terms for the other party. + * + * FIXME: Nail down type! + */ + contractTerms: any; +} + /** * Record for a push P2P payment that this wallet was offered. * @@ -1825,6 +1853,15 @@ export const WalletStoresV1 = { ]), }, ), + peerPullPaymentInitiation: describeStore( + describeContents<PeerPullPaymentInitiationRecord>( + "peerPushPaymentInitiation", + { + keyPath: "pursePub", + }, + ), + {}, + ), }; export interface MetaConfigRecord { 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 4d2f2bb5f..eca319a29 100644 --- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts +++ b/packages/taler-wallet-core/src/operations/peer-to-peer.ts @@ -37,7 +37,10 @@ import { eddsaGetPublic, encodeCrock, ExchangePurseMergeRequest, + ExchangeReservePurseRequest, getRandomBytes, + InitiatePeerPullPaymentRequest, + InitiatePeerPullPaymentResponse, InitiatePeerPushPaymentRequest, InitiatePeerPushPaymentResponse, j2s, @@ -370,24 +373,12 @@ export function talerPaytoFromExchangeReserve( return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; } -export async function acceptPeerPushPayment( +async function getMergeReserveInfo( ws: InternalWalletState, - req: AcceptPeerPushPaymentRequest, -) { - const peerInc = await ws.db - .mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming })) - .runReadOnly(async (tx) => { - return tx.peerPushPaymentIncoming.get(req.peerPushPaymentIncomingId); - }); - - if (!peerInc) { - throw Error( - `can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`, - ); - } - - const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount); - + req: { + exchangeBaseUrl: string; + }, +): Promise<MergeReserveInfo> { // We have to eagerly create the key pair outside of the transaction, // due to the async crypto API. const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); @@ -398,7 +389,7 @@ export async function acceptPeerPushPayment( withdrawalGroups: x.withdrawalGroups, })) .runReadWrite(async (tx) => { - const ex = await tx.exchanges.get(peerInc.exchangeBaseUrl); + const ex = await tx.exchanges.get(req.exchangeBaseUrl); checkDbInvariant(!!ex); if (ex.currentMergeReserveInfo) { return ex.currentMergeReserveInfo; @@ -411,6 +402,31 @@ export async function acceptPeerPushPayment( return ex.currentMergeReserveInfo; }); + return mergeReserveInfo; +} + +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.peerPushPaymentIncomingId); + }); + + if (!peerInc) { + throw Error( + `can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`, + ); + } + + const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount); + + const mergeReserveInfo = await getMergeReserveInfo(ws, { + exchangeBaseUrl: peerInc.exchangeBaseUrl, + }); + const mergeTimestamp = TalerProtocolTimestamp.now(); const reservePayto = talerPaytoFromExchangeReserve( @@ -461,3 +477,115 @@ export async function acceptPeerPushPayment( }, }); } + +export async function initiatePeerRequestForPay( + ws: InternalWalletState, + req: InitiatePeerPullPaymentRequest, +): Promise<InitiatePeerPullPaymentResponse> { + const mergeReserveInfo = await getMergeReserveInfo(ws, { + exchangeBaseUrl: req.exchangeBaseUrl, + }); + + const mergeTimestamp = TalerProtocolTimestamp.now(); + + const pursePair = await ws.cryptoApi.createEddsaKeypair({}); + const mergePair = await ws.cryptoApi.createEddsaKeypair({}); + + const purseExpiration: TalerProtocolTimestamp = AbsoluteTime.toTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 2 }), + ), + ); + + const reservePayto = talerPaytoFromExchangeReserve( + req.exchangeBaseUrl, + mergeReserveInfo.reservePub, + ); + + const contractTerms = { + ...req.partialContractTerms, + amount: req.amount, + purse_expiration: purseExpiration, + }; + + const econtractResp = await ws.cryptoApi.encryptContractForDeposit({ + contractTerms, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + }); + + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + + const purseFee = Amounts.stringify( + Amounts.getZero(Amounts.parseOrThrow(req.amount).currency), + ); + + const sigRes = await ws.cryptoApi.signReservePurseCreate({ + contractTermsHash: hContractTerms, + flags: WalletAccountMergeFlags.CreateWithPurseFee, + mergePriv: mergePair.priv, + mergeTimestamp: mergeTimestamp, + purseAmount: req.amount, + purseExpiration: purseExpiration, + purseFee: purseFee, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + reservePayto, + reservePriv: mergeReserveInfo.reservePriv, + }); + + await ws.db + .mktx((x) => ({ + peerPullPaymentInitiation: x.peerPullPaymentInitiation, + })) + .runReadWrite(async (tx) => { + await tx.peerPullPaymentInitiation.put({ + amount: req.amount, + contractTerms, + exchangeBaseUrl: req.exchangeBaseUrl, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + }); + }); + + const reservePurseReqBody: ExchangeReservePurseRequest = { + merge_sig: sigRes.mergeSig, + merge_timestamp: mergeTimestamp, + h_contract_terms: hContractTerms, + merge_pub: mergePair.pub, + min_age: 0, + purse_expiration: purseExpiration, + purse_fee: purseFee, + purse_pub: pursePair.pub, + purse_sig: sigRes.purseSig, + purse_value: req.amount, + reserve_sig: sigRes.accountSig, + econtract: econtractResp.econtract, + }; + + logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`); + + const reservePurseMergeUrl = new URL( + `reserves/${mergeReserveInfo.reservePub}/purse`, + req.exchangeBaseUrl, + ); + + const httpResp = await ws.http.postJson( + reservePurseMergeUrl.href, + reservePurseReqBody, + ); + + const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); + + logger.info(`reserve merge response: ${j2s(resp)}`); + + // FIXME: Now create a withdrawal operation! + + return { + talerUri: constructPayPushUri({ + exchangeBaseUrl: req.exchangeBaseUrl, + contractPriv: econtractResp.contractPriv, + }), + }; +} diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index cc9e98f8c..14c40a8db 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, + AcceptPeerPullPaymentRequest, AcceptPeerPushPaymentRequest, AcceptTipRequest, AcceptWithdrawalResponse, @@ -35,6 +36,8 @@ import { ApplyRefundResponse, BackupRecovery, BalancesResponse, + CheckPeerPullPaymentRequest, + CheckPeerPullPaymentResponse, CheckPeerPushPaymentRequest, CheckPeerPushPaymentResponse, CoinDumpJson, @@ -49,6 +52,8 @@ import { GetExchangeTosResult, GetWithdrawalDetailsForAmountRequest, GetWithdrawalDetailsForUriRequest, + InitiatePeerPullPaymentRequest, + InitiatePeerPullPaymentResponse, InitiatePeerPushPaymentRequest, InitiatePeerPushPaymentResponse, IntegrationTestArgs, @@ -126,6 +131,9 @@ export enum WalletApiOperation { InitiatePeerPushPayment = "initiatePeerPushPayment", CheckPeerPushPayment = "checkPeerPushPayment", AcceptPeerPushPayment = "acceptPeerPushPayment", + InitiatePeerPullPayment = "initiatePeerPullPayment", + CheckPeerPullPayment = "checkPeerPullPayment", + AcceptPeerPullPayment = "acceptPeerPullPayment", } export type WalletOperations = { @@ -297,6 +305,18 @@ export type WalletOperations = { request: AcceptPeerPushPaymentRequest; response: {}; }; + [WalletApiOperation.InitiatePeerPullPayment]: { + request: InitiatePeerPullPaymentRequest; + response: InitiatePeerPullPaymentResponse; + }; + [WalletApiOperation.CheckPeerPullPayment]: { + request: CheckPeerPullPaymentRequest; + response: CheckPeerPullPaymentResponse; + }; + [WalletApiOperation.AcceptPeerPullPayment]: { + request: AcceptPeerPullPaymentRequest; + response: {}; + }; }; export type RequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 593d2e0ff..0d5918886 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -32,6 +32,7 @@ import { codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, codecForAcceptManualWithdrawalRequet, + codecForAcceptPeerPullPaymentRequest, codecForAcceptPeerPushPaymentRequest, codecForAcceptTipRequest, codecForAddExchangeRequest, @@ -50,6 +51,7 @@ import { codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForImportDbRequest, + codecForInitiatePeerPullPaymentRequest, codecForInitiatePeerPushPaymentRequest, codecForIntegrationTestArgs, codecForListKnownBankAccounts, @@ -150,6 +152,7 @@ import { import { acceptPeerPushPayment, checkPeerPushPayment, + initiatePeerRequestForPay, initiatePeerToPeerPush, } from "./operations/peer-to-peer.js"; import { getPendingOperations } from "./operations/pending.js"; @@ -455,11 +458,20 @@ async function fillDefaults(ws: InternalWalletState): Promise<void> { for (const c of builtinAuditors) { await tx.auditorTrustStore.put(c); } - for (const url of builtinExchanges) { - await updateExchangeFromUrl(ws, url, { forceNow: true }); - } } + // FIXME: make sure exchanges are added transactionally to + // DB in first-time default application }); + + for (const url of builtinExchanges) { + try { + await updateExchangeFromUrl(ws, url, { forceNow: true }); + } catch (e) { + logger.warn( + `could not update builtin exchange ${url} during wallet initialization`, + ); + } + } } async function getExchangeTos( @@ -568,8 +580,9 @@ async function getExchanges( continue; } - const denominations = await tx.denominations.indexes - .byExchangeBaseUrl.iter(r.baseUrl).toArray(); + const denominations = await tx.denominations.indexes.byExchangeBaseUrl + .iter(r.baseUrl) + .toArray(); if (!denominations) { continue; @@ -1030,6 +1043,10 @@ async function dispatchRequestInternal( await acceptPeerPushPayment(ws, req); return {}; } + case "initiatePeerPullPayment": { + const req = codecForInitiatePeerPullPaymentRequest().decode(payload); + return await initiatePeerRequestForPay(ws, req); + } } throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, |