From f11483b511ff1f839b9913c4832eee9109f67aeb Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 12 Jul 2022 17:41:14 +0200 Subject: wallet-core: implement accepting p2p push payments --- packages/anastasis-core/src/crypto.ts | 6 +- packages/taler-util/package.json | 1 + packages/taler-util/src/index.ts | 1 + packages/taler-util/src/talerCrypto.test.ts | 28 ++- packages/taler-util/src/talerCrypto.ts | 253 ++++++++++++++----- packages/taler-util/src/talerTypes.ts | 42 ++++ packages/taler-util/src/walletTypes.ts | 35 +++ packages/taler-wallet-cli/src/index.ts | 9 +- .../src/integrationtests/test-peer-to-peer.ts | 26 ++ .../src/crypto/cryptoImplementation.ts | 132 +++++++++- .../taler-wallet-core/src/crypto/cryptoTypes.ts | 83 ++++++- packages/taler-wallet-core/src/db.ts | 40 ++- .../src/operations/backup/import.ts | 52 ++-- packages/taler-wallet-core/src/operations/pay.ts | 2 +- .../src/operations/peer-to-peer.ts | 270 +++++++++++++++++++-- .../src/util/contractTerms.test.ts | 122 ---------- .../taler-wallet-core/src/util/contractTerms.ts | 230 ------------------ packages/taler-wallet-core/src/wallet-api-types.ts | 11 + packages/taler-wallet-core/src/wallet.ts | 17 +- pnpm-lock.yaml | 2 + 20 files changed, 898 insertions(+), 464 deletions(-) delete mode 100644 packages/taler-wallet-core/src/util/contractTerms.test.ts delete mode 100644 packages/taler-wallet-core/src/util/contractTerms.ts diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts index 815f84c11..5e45f995f 100644 --- a/packages/anastasis-core/src/crypto.ts +++ b/packages/anastasis-core/src/crypto.ts @@ -227,11 +227,11 @@ async function anastasisDecrypt( const nonceBuf = ctBuf.slice(0, nonceSize); const enc = ctBuf.slice(nonceSize); const key = await deriveKey(keySeed, encodeCrock(nonceBuf), salt); - const cipherText = secretbox_open(enc, nonceBuf, key); - if (!cipherText) { + const clearText = secretbox_open(enc, nonceBuf, key); + if (!clearText) { throw Error("could not decrypt"); } - return encodeCrock(cipherText); + return encodeCrock(clearText); } export const asOpaque = (x: string): OpaqueData => x; diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json index 42ca8cb2a..af87742cd 100644 --- a/packages/taler-util/package.json +++ b/packages/taler-util/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "big-integer": "^1.6.51", + "fflate": "^0.7.3", "jed": "^1.1.1", "tslib": "^2.3.1" }, diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts index 199218d69..cf48ba803 100644 --- a/packages/taler-util/src/index.ts +++ b/packages/taler-util/src/index.ts @@ -32,3 +32,4 @@ export { } from "./nacl-fast.js"; export { RequestThrottler } from "./RequestThrottler.js"; export * from "./CancellationToken.js"; +export * from "./contractTerms.js"; diff --git a/packages/taler-util/src/talerCrypto.test.ts b/packages/taler-util/src/talerCrypto.test.ts index 5e8f37d80..b4a0106fa 100644 --- a/packages/taler-util/src/talerCrypto.test.ts +++ b/packages/taler-util/src/talerCrypto.test.ts @@ -374,7 +374,7 @@ test("taler age restriction crypto", async (t) => { const priv1 = await Edx25519.keyCreate(); const pub1 = await Edx25519.getPublic(priv1); - const seed = encodeCrock(getRandomBytes(32)); + const seed = getRandomBytes(32); const priv2 = await Edx25519.privateKeyDerive(priv1, seed); const pub2 = await Edx25519.publicKeyDerive(pub1, seed); @@ -392,18 +392,18 @@ test("edx signing", async (t) => { const sig = nacl.crypto_edx25519_sign_detached( msg, - decodeCrock(priv1), - decodeCrock(pub1), + priv1, + pub1, ); t.true( - nacl.crypto_edx25519_sign_detached_verify(msg, sig, decodeCrock(pub1)), + nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1), ); sig[0]++; t.false( - nacl.crypto_edx25519_sign_detached_verify(msg, sig, decodeCrock(pub1)), + nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1), ); }); @@ -421,13 +421,19 @@ test("edx test vector", async (t) => { }; { - const pub1Prime = await Edx25519.getPublic(tv.priv1_edx); - t.is(pub1Prime, tv.pub1_edx); + const pub1Prime = await Edx25519.getPublic(decodeCrock(tv.priv1_edx)); + t.is(pub1Prime, decodeCrock(tv.pub1_edx)); } - const pub2Prime = await Edx25519.publicKeyDerive(tv.pub1_edx, tv.seed); - t.is(pub2Prime, tv.pub2_edx); + const pub2Prime = await Edx25519.publicKeyDerive( + decodeCrock(tv.pub1_edx), + decodeCrock(tv.seed), + ); + t.is(pub2Prime, decodeCrock(tv.pub2_edx)); - const priv2Prime = await Edx25519.privateKeyDerive(tv.priv1_edx, tv.seed); - t.is(priv2Prime, tv.priv2_edx); + const priv2Prime = await Edx25519.privateKeyDerive( + decodeCrock(tv.priv1_edx), + decodeCrock(tv.seed), + ); + t.is(priv2Prime, decodeCrock(tv.priv2_edx)); }); diff --git a/packages/taler-util/src/talerCrypto.ts b/packages/taler-util/src/talerCrypto.ts index 188f5ec0a..5de767dda 100644 --- a/packages/taler-util/src/talerCrypto.ts +++ b/packages/taler-util/src/talerCrypto.ts @@ -25,7 +25,6 @@ import * as nacl from "./nacl-fast.js"; import { kdf, kdfKw } from "./kdf.js"; import bigint from "big-integer"; import { - Base32String, CoinEnvelope, CoinPublicKeyString, DenominationPubKey, @@ -33,11 +32,29 @@ import { HashCodeString, } from "./talerTypes.js"; import { Logger } from "./logging.js"; +import { secretbox } from "./nacl-fast.js"; +import * as fflate from "fflate"; +import { canonicalJson } from "./helpers.js"; + +export type Flavor = T & { + _flavor?: `taler.${FlavorT}`; +}; + +export type FlavorP = T & { + _flavor?: `taler.${FlavorT}`; + _size?: S; +}; export function getRandomBytes(n: number): Uint8Array { return nacl.randomBytes(n); } +export function getRandomBytesF( + n: T, +): FlavorP { + return nacl.randomBytes(n); +} + const encTable = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; class EncodingError extends Error { @@ -157,8 +174,8 @@ export function keyExchangeEddsaEcdhe( } export function keyExchangeEcdheEddsa( - ecdhePriv: Uint8Array, - eddsaPub: Uint8Array, + ecdhePriv: Uint8Array & MaterialEcdhePriv, + eddsaPub: Uint8Array & MaterialEddsaPub, ): Uint8Array { const curve25519Pub = nacl.sign_ed25519_pk_to_curve25519(eddsaPub); const x = nacl.scalarMult(ecdhePriv, curve25519Pub); @@ -679,7 +696,8 @@ export function hashDenomPub(pub: DenominationPubKey): Uint8Array { return nacl.hash(uint8ArrayBuf); } else { throw Error( - `unsupported cipher (${(pub as DenominationPubKey).cipher + `unsupported cipher (${ + (pub as DenominationPubKey).cipher }), unable to hash`, ); } @@ -775,6 +793,9 @@ export enum TalerSignaturePurpose { WALLET_AGE_ATTESTATION = 1207, WALLET_PURSE_CREATE = 1210, WALLET_PURSE_DEPOSIT = 1211, + WALLET_PURSE_MERGE = 1213, + WALLET_ACCOUNT_MERGE = 1214, + WALLET_PURSE_ECONTRACT = 1216, EXCHANGE_CONFIRM_RECOUP = 1039, EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041, ANASTASIS_POLICY_UPLOAD = 1400, @@ -782,10 +803,26 @@ export enum TalerSignaturePurpose { SYNC_BACKUP_UPLOAD = 1450, } +export const enum WalletAccountMergeFlags { + /** + * Not a legal mode! + */ + None = 0, + + /** + * We are merging a fully paid-up purse into a reserve. + */ + MergeFullyPaidPurse = 1, + + CreateFromPurseQuota = 2, + + CreateWithPurseFee = 3, +} + export class SignaturePurposeBuilder { private chunks: Uint8Array[] = []; - constructor(private purposeNum: number) { } + constructor(private purposeNum: number) {} put(bytes: Uint8Array): SignaturePurposeBuilder { this.chunks.push(Uint8Array.from(bytes)); @@ -815,19 +852,10 @@ export function buildSigPS(purposeNum: number): SignaturePurposeBuilder { return new SignaturePurposeBuilder(purposeNum); } -export type Flavor = T & { - _flavor?: `taler.${FlavorT}`; -}; - -export type FlavorP = T & { - _flavor?: `taler.${FlavorT}`; - _size?: S; -}; - -export type OpaqueData = Flavor; -export type Edx25519PublicKey = FlavorP; -export type Edx25519PrivateKey = FlavorP; -export type Edx25519Signature = FlavorP; +export type OpaqueData = Flavor; +export type Edx25519PublicKey = FlavorP; +export type Edx25519PrivateKey = FlavorP; +export type Edx25519Signature = FlavorP; /** * Convert a big integer to a fixed-size, little-endian array. @@ -859,19 +887,17 @@ export namespace Edx25519 { export async function keyCreateFromSeed( seed: OpaqueData, ): Promise { - return encodeCrock( - nacl.crypto_edx25519_private_key_create_from_seed(decodeCrock(seed)), - ); + return nacl.crypto_edx25519_private_key_create_from_seed(seed); } export async function keyCreate(): Promise { - return encodeCrock(nacl.crypto_edx25519_private_key_create()); + return nacl.crypto_edx25519_private_key_create(); } export async function getPublic( priv: Edx25519PrivateKey, ): Promise { - return encodeCrock(nacl.crypto_edx25519_get_public(decodeCrock(priv))); + return nacl.crypto_edx25519_get_public(priv); } export function sign( @@ -887,12 +913,12 @@ export namespace Edx25519 { ): Promise { const res = kdfKw({ outputLength: 64, - salt: decodeCrock(seed), - ikm: decodeCrock(pub), - info: stringToBytes("edx25519-derivation"), + salt: seed, + ikm: pub, + info: stringToBytes("edx2559-derivation"), }); - return encodeCrock(res); + return res; } export async function privateKeyDerive( @@ -900,21 +926,17 @@ export namespace Edx25519 { seed: OpaqueData, ): Promise { const pub = await getPublic(priv); - const privDec = decodeCrock(priv); + const privDec = priv; const a = bigintFromNaclArr(privDec.subarray(0, 32)); const factorEnc = await deriveFactor(pub, seed); - const factorModL = bigintFromNaclArr(decodeCrock(factorEnc)).mod(L); + const factorModL = bigintFromNaclArr(factorEnc).mod(L); const aPrime = a.divide(8).multiply(factorModL).mod(L).multiply(8).mod(L); const bPrime = nacl - .hash( - typedArrayConcat([privDec.subarray(32, 64), decodeCrock(factorEnc)]), - ) + .hash(typedArrayConcat([privDec.subarray(32, 64), factorEnc])) .subarray(0, 32); - const newPriv = encodeCrock( - typedArrayConcat([bigintToNaclArr(aPrime, 32), bPrime]), - ); + const newPriv = typedArrayConcat([bigintToNaclArr(aPrime, 32), bPrime]); return newPriv; } @@ -924,14 +946,9 @@ export namespace Edx25519 { seed: OpaqueData, ): Promise { const factorEnc = await deriveFactor(pub, seed); - const factorReduced = nacl.crypto_core_ed25519_scalar_reduce( - decodeCrock(factorEnc), - ); - const res = nacl.crypto_scalarmult_ed25519_noclamp( - factorReduced, - decodeCrock(pub), - ); - return encodeCrock(res); + const factorReduced = nacl.crypto_core_ed25519_scalar_reduce(factorEnc); + const res = nacl.crypto_scalarmult_ed25519_noclamp(factorReduced, pub); + return res; } } @@ -967,7 +984,7 @@ export namespace AgeRestriction { export function hashCommitment(ac: AgeCommitment): HashCodeString { const hc = new nacl.HashState(); for (const pub of ac.publicKeys) { - hc.update(decodeCrock(pub)); + hc.update(pub); } return encodeCrock(hc.finish().subarray(0, 32)); } @@ -1091,16 +1108,12 @@ export namespace AgeRestriction { const group = getAgeGroupIndex(commitmentProof.commitment.mask, age); if (group === 0) { // No attestation required. - return encodeCrock(new Uint8Array(64)); + return new Uint8Array(64); } const priv = commitmentProof.proof.privateKeys[group - 1]; const pub = commitmentProof.commitment.publicKeys[group - 1]; - const sig = nacl.crypto_edx25519_sign_detached( - d, - decodeCrock(priv), - decodeCrock(pub), - ); - return encodeCrock(sig); + const sig = nacl.crypto_edx25519_sign_detached(d, priv, pub); + return sig; } export function commitmentVerify( @@ -1118,10 +1131,138 @@ export namespace AgeRestriction { return true; } const pub = commitment.publicKeys[group - 1]; - return nacl.crypto_edx25519_sign_detached_verify( - d, - decodeCrock(sig), - decodeCrock(pub), - ); + return nacl.crypto_edx25519_sign_detached_verify(d, decodeCrock(sig), pub); } } + +// FIXME: make it a branded type! +type EncryptionNonce = FlavorP; + +async function deriveKey( + keySeed: OpaqueData, + nonce: EncryptionNonce, + salt: string, +): Promise { + return kdfKw({ + outputLength: 32, + salt: nonce, + ikm: keySeed, + info: stringToBytes(salt), + }); +} + +async function encryptWithDerivedKey( + nonce: EncryptionNonce, + keySeed: OpaqueData, + plaintext: OpaqueData, + salt: string, +): Promise { + const key = await deriveKey(keySeed, nonce, salt); + const cipherText = secretbox(plaintext, nonce, key); + return typedArrayConcat([nonce, cipherText]); +} + +const nonceSize = 24; + +async function decryptWithDerivedKey( + ciphertext: OpaqueData, + keySeed: OpaqueData, + salt: string, +): Promise { + const ctBuf = ciphertext; + const nonceBuf = ctBuf.slice(0, nonceSize); + const enc = ctBuf.slice(nonceSize); + const key = await deriveKey(keySeed, nonceBuf, salt); + const clearText = nacl.secretbox_open(enc, nonceBuf, key); + if (!clearText) { + throw Error("could not decrypt"); + } + return clearText; +} + +enum ContractFormatTag { + PaymentOffer = 0, + PaymentRequest = 1, +} + +type MaterialEddsaPub = { + _materialType?: "eddsa-pub"; + _size?: 32; +}; + +type MaterialEddsaPriv = { + _materialType?: "ecdhe-priv"; + _size?: 32; +}; + +type MaterialEcdhePub = { + _materialType?: "ecdhe-pub"; + _size?: 32; +}; + +type MaterialEcdhePriv = { + _materialType?: "ecdhe-priv"; + _size?: 32; +}; + +type PursePublicKey = FlavorP & + MaterialEddsaPub; + +type ContractPrivateKey = FlavorP & + MaterialEcdhePriv; + +type MergePrivateKey = FlavorP & + MaterialEddsaPriv; + +export function encryptContractForMerge( + pursePub: PursePublicKey, + contractPriv: ContractPrivateKey, + mergePriv: MergePrivateKey, + contractTerms: any, +): Promise { + const contractTermsCanon = canonicalJson(contractTerms) + "\0"; + const contractTermsBytes = stringToBytes(contractTermsCanon); + const contractTermsCompressed = fflate.zlibSync(contractTermsBytes); + const data = typedArrayConcat([ + bufferForUint32(ContractFormatTag.PaymentOffer), + bufferForUint32(contractTermsBytes.length), + mergePriv, + contractTermsCompressed, + ]); + const key = keyExchangeEcdheEddsa(contractPriv, pursePub); + return encryptWithDerivedKey( + getRandomBytesF(24), + key, + data, + "p2p-merge-contract", + ); +} + +export interface DecryptForMergeResult { + contractTerms: any; + mergePriv: Uint8Array; +} + +export async function decryptContractForMerge( + enc: OpaqueData, + pursePub: PursePublicKey, + contractPriv: ContractPrivateKey, +): Promise { + const key = keyExchangeEcdheEddsa(contractPriv, pursePub); + const dec = await decryptWithDerivedKey(enc, key, "p2p-merge-contract"); + const mergePriv = dec.slice(8, 8 + 32); + const contractTermsCompressed = dec.slice(8 + 32); + 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 { + mergePriv: mergePriv, + contractTerms: JSON.parse(contractTermsString), + }; +} + +export function encryptContractForDeposit() { + throw Error("not implemented"); +} diff --git a/packages/taler-util/src/talerTypes.ts b/packages/taler-util/src/talerTypes.ts index 7afa76e9e..d4de8c37b 100644 --- a/packages/taler-util/src/talerTypes.ts +++ b/packages/taler-util/src/talerTypes.ts @@ -1832,3 +1832,45 @@ export interface PurseDeposit { */ coin_pub: EddsaPublicKeyString; } + +export interface ExchangePurseMergeRequest { + // payto://-URI of the account the purse is to be merged into. + // Must be of the form: 'payto://taler/$EXCHANGE_URL/$RESERVE_PUB'. + payto_uri: string; + + // EdDSA signature of the account/reserve affirming the merge + // over a TALER_AccountMergeSignaturePS. + // Must be of purpose TALER_SIGNATURE_ACCOUNT_MERGE + reserve_sig: EddsaSignatureString; + + // 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; + + // Client-side timestamp of when the merge request was made. + merge_timestamp: TalerProtocolTimestamp; +} + +export interface ExchangeGetContractResponse { + purse_pub: string; + econtract_sig: string; + econtract: string; +} + +export const codecForExchangeGetContractResponse = + (): Codec => + buildCodecForObject() + .property("purse_pub", codecForString()) + .property("econtract_sig", codecForString()) + .property("econtract", codecForString()) + .build("ExchangeGetContractResponse"); + +/** + * Contract terms between two wallets (as opposed to a merchant and wallet). + */ +export interface PeerContractTerms { + amount: AmountString; + summary: string; + purse_expiration: TalerProtocolTimestamp; +} diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index 4b1911164..245b5654e 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -1263,15 +1263,50 @@ export interface PayCoinSelection { export interface InitiatePeerPushPaymentRequest { amount: AmountString; + partialContractTerms: any; } export interface InitiatePeerPushPaymentResponse { + exchangeBaseUrl: string; pursePub: string; mergePriv: string; + contractPriv: string; } export const codecForInitiatePeerPushPaymentRequest = (): Codec => buildCodecForObject() .property("amount", codecForAmountString()) + .property("partialContractTerms", codecForAny()) .build("InitiatePeerPushPaymentRequest"); + +export interface CheckPeerPushPaymentRequest { + exchangeBaseUrl: string; + pursePub: string; + contractPriv: string; +} + +export interface CheckPeerPushPaymentResponse { + contractTerms: any; + amount: AmountString; +} + +export const codecForCheckPeerPushPaymentRequest = + (): Codec => + buildCodecForObject() + .property("pursePub", codecForString()) + .property("contractPriv", codecForString()) + .property("exchangeBaseUrl", codecForString()) + .build("CheckPeerPushPaymentRequest"); + +export interface AcceptPeerPushPaymentRequest { + exchangeBaseUrl: string; + pursePub: string; +} + +export const codecForAcceptPeerPushPaymentRequest = + (): Codec => + buildCodecForObject() + .property("pursePub", codecForString()) + .property("exchangeBaseUrl", codecForString()) + .build("AcceptPeerPushPaymentRequest"); diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index ebcee2054..a1073dc31 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -1149,7 +1149,7 @@ testCli tVerify.start(); const attestRes = AgeRestriction.commitmentVerify( commitProof.commitment, - attest, + encodeCrock(attest), 18, ); tVerify.stop(); @@ -1157,9 +1157,12 @@ testCli throw Error(); } - const salt = encodeCrock(getRandomBytes(32)); + const salt = getRandomBytes(32); tDerive.start(); - const deriv = await AgeRestriction.commitmentDerive(commitProof, salt); + const deriv = await AgeRestriction.commitmentDerive( + commitProof, + salt, + ); tDerive.stop(); tCompare.start(); 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.ts index 4d27f45d7..5c716dc54 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-peer-to-peer.ts @@ -44,10 +44,36 @@ export async function runPeerToPeerTest(t: GlobalTestState) { WalletApiOperation.InitiatePeerPushPayment, { amount: "TESTKUDOS:5", + partialContractTerms: { + summary: "Hello World", + }, }, ); console.log(resp); + + const checkResp = await wallet.client.call( + WalletApiOperation.CheckPeerPushPayment, + { + contractPriv: resp.contractPriv, + exchangeBaseUrl: resp.exchangeBaseUrl, + pursePub: resp.pursePub, + }, + ); + + console.log(checkResp); + + const acceptResp = await wallet.client.call( + WalletApiOperation.AcceptPeerPushPayment, + { + exchangeBaseUrl: resp.exchangeBaseUrl, + pursePub: resp.pursePub, + }, + ); + + console.log(acceptResp); + + await wallet.runUntilDone(); } runPeerToPeerTest.suites = ["wallet"]; 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; + + encryptContractForMerge( + req: EncryptContractRequest, + ): Promise; + + decryptContractForMerge( + req: DecryptContractRequest, + ): Promise; + + signPurseMerge(req: SignPurseMergeRequest): Promise; } /** @@ -326,6 +346,21 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise { throw new Error("Function not implemented."); }, + encryptContractForMerge: function ( + req: EncryptContractRequest, + ): Promise { + throw new Error("Function not implemented."); + }, + decryptContractForMerge: function ( + req: DecryptContractRequest, + ): Promise { + throw new Error("Function not implemented."); + }, + signPurseMerge: function ( + req: SignPurseMergeRequest, + ): Promise { + throw new Error("Function not implemented."); + }, }; export type WithArg = 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 { + 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 { + 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 { + 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("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 => + buildCodecForObject() + .property("balance", codecForAmountString()) + .build("ExchangePurseStatus"); + +export async function checkPeerPushPayment( + ws: InternalWalletState, + req: CheckPeerPushPaymentRequest, +): Promise { + 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 - */ - -/** - * 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 - */ - -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 190746c95..43bedddd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,7 @@ importers: ava: ^4.0.1 big-integer: ^1.6.51 esbuild: ^0.14.21 + fflate: ^0.7.3 jed: ^1.1.1 prettier: ^2.5.1 rimraf: ^3.0.2 @@ -176,6 +177,7 @@ importers: typescript: ^4.5.5 dependencies: big-integer: 1.6.51 + fflate: 0.7.3 jed: 1.1.1 tslib: 2.3.1 devDependencies: -- cgit v1.2.3