wallet-core: P2P push payments (still incomplete)

This commit is contained in:
Florian Dold 2022-06-21 12:40:12 +02:00
parent 05cdbfb534
commit b214934b75
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
12 changed files with 547 additions and 40 deletions

View File

@ -773,6 +773,8 @@ export enum TalerSignaturePurpose {
WALLET_COIN_LINK = 1204,
WALLET_COIN_RECOUP_REFRESH = 1206,
WALLET_AGE_ATTESTATION = 1207,
WALLET_PURSE_CREATE = 1210,
WALLET_PURSE_DEPOSIT = 1211,
EXCHANGE_CONFIRM_RECOUP = 1039,
EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041,
ANASTASIS_POLICY_UPLOAD = 1400,

View File

@ -1794,3 +1794,41 @@ export const codecForDepositSuccess = (): Codec<DepositSuccess> =>
.property("exchange_timestamp", codecForTimestamp)
.property("transaction_base_url", codecOptional(codecForString()))
.build("DepositSuccess");
export interface PurseDeposit {
/**
* Amount to be deposited, can be a fraction of the
* coin's total value.
*/
amount: AmountString;
/**
* Hash of denomination RSA key with which the coin is signed.
*/
denom_pub_hash: HashCodeString;
/**
* Exchange's unblinded RSA signature of the coin.
*/
ub_sig: UnblindedSignature;
/**
* Age commitment hash for the coin, if the denomination is age-restricted.
*/
h_age_commitment?: HashCodeString;
// FIXME-Oec: proof of age is missing.
/**
* Signature over TALER_PurseDepositSignaturePS
* of purpose TALER_SIGNATURE_WALLET_PURSE_DEPOSIT
* made by the customer with the
* coin's private key.
*/
coin_sig: EddsaSignatureString;
/**
* Public key of the coin being deposited into the purse.
*/
coin_pub: EddsaPublicKeyString;
}

View File

@ -32,10 +32,7 @@ import {
codecForAmountJson,
codecForAmountString,
} from "./amounts.js";
import {
codecForTimestamp,
TalerProtocolTimestamp,
} from "./time.js";
import { codecForTimestamp, TalerProtocolTimestamp } from "./time.js";
import {
buildCodecForObject,
codecForString,
@ -1230,15 +1227,14 @@ export interface ForcedCoinSel {
}
export interface TestPayResult {
payCoinSelection: PayCoinSelection,
payCoinSelection: PayCoinSelection;
}
/**
* Result of selecting coins, contains the exchange, and selected
* coins with their denomination.
*/
export interface PayCoinSelection {
export interface PayCoinSelection {
/**
* Amount requested by the merchant.
*/
@ -1264,3 +1260,18 @@ export interface TestPayResult {
*/
customerDepositFees: AmountJson;
}
export interface InitiatePeerPushPaymentRequest {
amount: AmountString;
}
export interface InitiatePeerPushPaymentResponse {
pursePub: string;
mergePriv: string;
}
export const codecForInitiatePeerPushPaymentRequest =
(): Codec<InitiatePeerPushPaymentRequest> =>
buildCodecForObject<InitiatePeerPushPaymentRequest>()
.property("amount", codecForAmountString())
.build("InitiatePeerPushPaymentRequest");

View File

@ -1296,6 +1296,36 @@ export class ExchangeService implements ExchangeServiceInterface {
);
}
}
await runCommand(
this.globalState,
"exchange-offline",
"taler-exchange-offline",
[
"-c",
this.configFilename,
"global-fee",
// year
"now",
// history fee
`${this.exchangeConfig.currency}:0.01`,
// kyc fee
`${this.exchangeConfig.currency}:0.01`,
// account fee
`${this.exchangeConfig.currency}:0.01`,
// purse fee
`${this.exchangeConfig.currency}:0.01`,
// purse timeout
"1h",
// kyc timeout
"1h",
// history expiration
"1year",
// free purses per account
"5",
"upload",
],
);
}
async revokeDenomination(denomPubHash: string) {

View File

@ -0,0 +1,53 @@
/*
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,
makeTestPayment,
} from "../harness/helpers.js";
/**
* Run test for basic, bank-integrated withdrawal and payment.
*/
export async function runPeerToPeerTest(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.InitiatePeerPushPayment,
{
amount: "TESTKUDOS:5",
},
);
console.log(resp);
}
runPeerToPeerTest.suites = ["wallet"];

View File

@ -73,6 +73,7 @@ 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 { runRefundTest } from "./test-refund";
import { runRefundAutoTest } from "./test-refund-auto";
import { runRefundGoneTest } from "./test-refund-gone";
@ -153,6 +154,7 @@ const allTests: TestMainFunction[] = [
runPaymentZeroTest,
runPayPaidTest,
runPaywallFlowTest,
runPeerToPeerTest,
runRefundAutoTest,
runRefundGoneTest,
runRefundIncrementalTest,

View File

@ -24,33 +24,44 @@
* Imports.
*/
// FIXME: Crypto should not use DB Types!
import {
AgeCommitmentProof,
AgeRestriction,
AmountJson,
Amounts,
AmountString,
BlindedDenominationSignature,
bufferForUint32,
buildSigPS,
CoinDepositPermission,
CoinEnvelope,
createEddsaKeyPair,
createHashContext,
decodeCrock,
DenomKeyType,
DepositInfo,
ecdheGetPublic,
eddsaGetPublic,
EddsaPublicKeyString,
eddsaSign,
eddsaVerify,
encodeCrock,
ExchangeProtocolVersion,
getRandomBytes,
hash,
HashCodeString,
hashCoinEv,
hashCoinEvInner,
hashCoinPub,
hashDenomPub,
hashTruncate32,
kdf,
kdfKw,
keyExchangeEcdheEddsa,
Logger,
MakeSyncSignatureRequest,
PlanchetCreationRequest,
WithdrawalPlanchet,
PlanchetUnblindInfo,
PurseDeposit,
RecoupRefreshRequest,
RecoupRequest,
RefreshPlanchetInfo,
@ -59,23 +70,14 @@ import {
rsaVerify,
setupTipPlanchet,
stringToBytes,
TalerSignaturePurpose,
BlindedDenominationSignature,
UnblindedSignature,
PlanchetUnblindInfo,
TalerProtocolTimestamp,
kdfKw,
bufferForUint32,
kdf,
ecdheGetPublic,
getRandomBytes,
AgeCommitmentProof,
AgeRestriction,
hashCoinPub,
HashCodeString,
TalerSignaturePurpose,
UnblindedSignature,
WithdrawalPlanchet,
} from "@gnu-taler/taler-util";
import bigint from "big-integer";
import { DenominationRecord, TipCoinSource, WireFee } from "../db.js";
// FIXME: Crypto should not use DB Types!
import { DenominationRecord, WireFee } from "../db.js";
import {
CreateRecoupRefreshReqRequest,
CreateRecoupReqRequest,
@ -177,6 +179,12 @@ export interface TalerCryptoInterface {
setupRefreshTransferPub(
req: SetupRefreshTransferPubRequest,
): Promise<TransferPubResponse>;
signPurseCreation(req: SignPurseCreationRequest): Promise<EddsaSigningResult>;
signPurseDeposits(
req: SignPurseDepositsRequest,
): Promise<SignPurseDepositsResponse>;
}
/**
@ -308,6 +316,16 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<TransferPubResponse> {
throw new Error("Function not implemented.");
},
signPurseCreation: function (
req: SignPurseCreationRequest,
): Promise<EddsaSigningResult> {
throw new Error("Function not implemented.");
},
signPurseDeposits: function (
req: SignPurseDepositsRequest,
): Promise<SignPurseDepositsResponse> {
throw new Error("Function not implemented.");
},
};
export type WithArg<X> = X extends (req: infer T) => infer R
@ -336,6 +354,31 @@ export interface SetupWithdrawalPlanchetRequest {
coinNumber: number;
}
export interface SignPurseCreationRequest {
pursePriv: string;
purseExpiration: TalerProtocolTimestamp;
purseAmount: AmountString;
hContractTerms: HashCodeString;
mergePub: EddsaPublicKeyString;
minAge: number;
}
export interface SignPurseDepositsRequest {
pursePub: string;
exchangeBaseUrl: string;
coins: {
coinPub: string;
coinPriv: string;
contribution: AmountString;
denomPubHash: string;
denomSig: UnblindedSignature;
}[];
}
export interface SignPurseDepositsResponse {
deposits: PurseDeposit[];
}
export interface RsaVerificationRequest {
hm: string;
sig: string;
@ -1212,6 +1255,51 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
transferPub: (await tci.ecdheGetPublic(tci, { priv: transferPriv })).pub,
};
},
async signPurseCreation(
tci: TalerCryptoInterfaceR,
req: SignPurseCreationRequest,
): Promise<EddsaSigningResult> {
const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_CREATE)
.put(timestampRoundedToBuffer(req.purseExpiration))
.put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
.put(decodeCrock(req.hContractTerms))
.put(decodeCrock(req.mergePub))
.put(bufferForUint32(req.minAge))
.build();
return await tci.eddsaSign(tci, {
msg: encodeCrock(sigBlob),
priv: req.pursePriv,
});
},
async signPurseDeposits(
tci: TalerCryptoInterfaceR,
req: SignPurseDepositsRequest,
): Promise<SignPurseDepositsResponse> {
const hExchangeBaseUrl = hash(stringToBytes(req.exchangeBaseUrl + "\0"));
const deposits: PurseDeposit[] = [];
for (const c of req.coins) {
const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT)
.put(amountToBuffer(Amounts.parseOrThrow(c.contribution)))
.put(decodeCrock(req.pursePub))
.put(hExchangeBaseUrl)
.build();
const sigResp = await tci.eddsaSign(tci, {
msg: encodeCrock(sigBlob),
priv: c.coinPriv,
});
deposits.push({
amount: c.contribution,
coin_pub: c.coinPub,
coin_sig: sigResp.sig,
denom_pub_hash: c.denomPubHash,
ub_sig: c.denomSig,
h_age_commitment: undefined,
});
}
return {
deposits,
};
},
};
function amountToBuffer(amount: AmountJson): Uint8Array {

View File

@ -1671,6 +1671,52 @@ export interface BalancePerCurrencyRecord {
pendingOutgoing: AmountString;
}
/**
* Record for a push P2P payment that this wallet initiated.
*/
export interface PeerPushPaymentInitiationRecord {
/**
* What exchange are funds coming from?
*/
exchangeBaseUrl: string;
amount: AmountString;
/**
* Purse public key. Used as the primary key to look
* up this record.
*/
pursePub: string;
/**
* Purse private key.
*/
pursePriv: string;
/**
* Public key of the merge capability of the purse.
*/
mergePub: string;
/**
* Private key of the merge capability of the purse.
*/
mergePriv: string;
purseExpiration: TalerProtocolTimestamp;
/**
* Did we successfully create the purse with the exchange?
*/
purseCreated: boolean;
}
/**
* Record for a push P2P payment that this wallet accepted.
*/
export interface PeerPushPaymentAcceptanceRecord {}
export const WalletStoresV1 = {
coins: describeStore(
describeContents<CoinRecord>("coins", {

View File

@ -0,0 +1,222 @@
/*
This file is part of GNU Taler
(C) 2019 GNUnet e.V.
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 {
AmountJson,
Amounts,
Logger,
InitiatePeerPushPaymentResponse,
InitiatePeerPushPaymentRequest,
strcmp,
CoinPublicKeyString,
j2s,
getRandomBytes,
Duration,
durationAdd,
TalerProtocolTimestamp,
AbsoluteTime,
encodeCrock,
AmountString,
UnblindedSignature,
} from "@gnu-taler/taler-util";
import { CoinStatus } from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
const logger = new Logger("operations/peer-to-peer.ts");
export interface PeerCoinSelection {
exchangeBaseUrl: string;
/**
* Info of Coins that were selected.
*/
coins: {
coinPub: string;
coinPriv: string;
contribution: AmountString;
denomPubHash: string;
denomSig: UnblindedSignature;
}[];
/**
* How much of the deposit fees is the customer paying?
*/
depositFees: AmountJson;
}
interface CoinInfo {
/**
* Public key of the coin.
*/
coinPub: string;
coinPriv: string;
/**
* Deposit fee for the coin.
*/
feeDeposit: AmountJson;
value: AmountJson;
denomPubHash: string;
denomSig: UnblindedSignature;
}
export async function initiatePeerToPeerPush(
ws: InternalWalletState,
req: InitiatePeerPushPaymentRequest,
): Promise<InitiatePeerPushPaymentResponse> {
const instructedAmount = Amounts.parseOrThrow(req.amount);
const coinSelRes: PeerCoinSelection | undefined = await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
coins: x.coins,
denominations: x.denominations,
}))
.runReadOnly(async (tx) => {
const exchanges = await tx.exchanges.iter().toArray();
for (const exch of exchanges) {
if (exch.detailsPointer?.currency !== instructedAmount.currency) {
continue;
}
const coins = (
await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl)
).filter((x) => x.status === CoinStatus.Fresh);
const coinInfos: CoinInfo[] = [];
for (const coin of coins) {
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
if (!denom) {
throw Error("denom not found");
}
coinInfos.push({
coinPub: coin.coinPub,
feeDeposit: denom.feeDeposit,
value: denom.value,
denomPubHash: denom.denomPubHash,
coinPriv: coin.coinPriv,
denomSig: coin.denomSig,
});
}
if (coinInfos.length === 0) {
continue;
}
coinInfos.sort(
(o1, o2) =>
-Amounts.cmp(o1.value, o2.value) ||
strcmp(o1.denomPubHash, o2.denomPubHash),
);
let amountAcc = Amounts.getZero(instructedAmount.currency);
let depositFeesAcc = Amounts.getZero(instructedAmount.currency);
const resCoins: {
coinPub: string;
coinPriv: string;
contribution: AmountString;
denomPubHash: string;
denomSig: UnblindedSignature;
}[] = [];
for (const coin of coinInfos) {
if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
const res: PeerCoinSelection = {
exchangeBaseUrl: exch.baseUrl,
coins: resCoins,
depositFees: depositFeesAcc,
};
return res;
}
const gap = Amounts.add(
coin.feeDeposit,
Amounts.sub(instructedAmount, amountAcc).amount,
).amount;
const contrib = Amounts.min(gap, coin.value);
amountAcc = Amounts.add(
amountAcc,
Amounts.sub(contrib, coin.feeDeposit).amount,
).amount;
depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount;
resCoins.push({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contribution: Amounts.stringify(contrib),
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
});
}
continue;
}
return undefined;
});
logger.info(`selected p2p coins: ${j2s(coinSelRes)}`);
if (!coinSelRes) {
throw Error("insufficient balance");
}
const pursePair = await ws.cryptoApi.createEddsaKeypair({});
const mergePair = await ws.cryptoApi.createEddsaKeypair({});
const hContractTerms = encodeCrock(getRandomBytes(64));
const purseExpiration = AbsoluteTime.toTimestamp(
AbsoluteTime.addDuration(
AbsoluteTime.now(),
Duration.fromSpec({ days: 2 }),
),
);
const purseSigResp = await ws.cryptoApi.signPurseCreation({
hContractTerms,
mergePub: mergePair.pub,
minAge: 0,
purseAmount: Amounts.stringify(instructedAmount),
purseExpiration,
pursePriv: pursePair.priv,
});
const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
pursePub: pursePair.pub,
coins: coinSelRes.coins,
});
const createPurseUrl = new URL(
`purses/${pursePair.pub}/create`,
coinSelRes.exchangeBaseUrl,
);
const httpResp = await ws.http.postJson(createPurseUrl.href, {
amount: Amounts.stringify(instructedAmount),
merge_pub: mergePair.pub,
purse_sig: purseSigResp.sig,
h_contract_terms: hContractTerms,
purse_expiration: purseExpiration,
deposits: depositSigsResp.deposits,
min_age: 0,
});
const resp = await httpResp.json();
logger.info(`resp: ${j2s(resp)}`);
throw Error("not yet implemented");
}

View File

@ -46,6 +46,8 @@ import {
GetExchangeTosResult,
GetWithdrawalDetailsForAmountRequest,
GetWithdrawalDetailsForUriRequest,
InitiatePeerPushPaymentRequest,
InitiatePeerPushPaymentResponse,
IntegrationTestArgs,
ManualWithdrawalDetails,
PreparePayRequest,
@ -118,6 +120,9 @@ export enum WalletApiOperation {
ExportBackupPlain = "exportBackupPlain",
WithdrawFakebank = "withdrawFakebank",
ExportDb = "exportDb",
InitiatePeerPushPayment = "initiatePeerPushPayment",
CheckPeerPushPayment = "checkPeerPushPayment",
AcceptPeerPushPayment = "acceptPeerPushPayment",
}
export type WalletOperations = {
@ -277,6 +282,10 @@ export type WalletOperations = {
request: {};
response: any;
};
[WalletApiOperation.InitiatePeerPushPayment]: {
request: InitiatePeerPushPaymentRequest;
response: InitiatePeerPushPaymentResponse;
};
};
export type RequestType<

View File

@ -47,6 +47,7 @@ import {
codecForGetWithdrawalDetailsForAmountRequest,
codecForGetWithdrawalDetailsForUri,
codecForImportDbRequest,
codecForInitiatePeerPushPaymentRequest,
codecForIntegrationTestArgs,
codecForListKnownBankAccounts,
codecForPrepareDepositRequest,
@ -143,6 +144,7 @@ import {
processDownloadProposal,
processPurchasePay,
} from "./operations/pay.js";
import { initiatePeerToPeerPush } from "./operations/peer-to-peer.js";
import { getPendingOperations } from "./operations/pending.js";
import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
import {
@ -1049,6 +1051,10 @@ async function dispatchRequestInternal(
await importDb(ws.db.idbHandle(), req.dump);
return [];
}
case "initiatePeerPushPayment": {
const req = codecForInitiatePeerPushPaymentRequest().decode(payload);
return await initiatePeerToPeerPush(ws, req);
}
}
throw TalerError.fromDetail(
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,