wallet-core: implement accepting p2p push payments

This commit is contained in:
Florian Dold 2022-07-12 17:41:14 +02:00
parent b214934b75
commit f11483b511
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
20 changed files with 900 additions and 466 deletions

View File

@ -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;

View File

@ -38,6 +38,7 @@
},
"dependencies": {
"big-integer": "^1.6.51",
"fflate": "^0.7.3",
"jed": "^1.1.1",
"tslib": "^2.3.1"
},

View File

@ -32,3 +32,4 @@ export {
} from "./nacl-fast.js";
export { RequestThrottler } from "./RequestThrottler.js";
export * from "./CancellationToken.js";
export * from "./contractTerms.js";

View File

@ -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));
});

View File

@ -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, FlavorT extends string> = T & {
_flavor?: `taler.${FlavorT}`;
};
export type FlavorP<T, FlavorT extends string, S extends number> = T & {
_flavor?: `taler.${FlavorT}`;
_size?: S;
};
export function getRandomBytes(n: number): Uint8Array {
return nacl.randomBytes(n);
}
export function getRandomBytesF<T extends number, N extends string>(
n: T,
): FlavorP<Uint8Array, N, T> {
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, FlavorT extends string> = T & {
_flavor?: `taler.${FlavorT}`;
};
export type FlavorP<T, FlavorT extends string, S extends number> = T & {
_flavor?: `taler.${FlavorT}`;
_size?: S;
};
export type OpaqueData = Flavor<string, "OpaqueData">;
export type Edx25519PublicKey = FlavorP<string, "Edx25519PublicKey", 32>;
export type Edx25519PrivateKey = FlavorP<string, "Edx25519PrivateKey", 64>;
export type Edx25519Signature = FlavorP<string, "Edx25519Signature", 64>;
export type OpaqueData = Flavor<Uint8Array, any>;
export type Edx25519PublicKey = FlavorP<Uint8Array, "Edx25519PublicKey", 32>;
export type Edx25519PrivateKey = FlavorP<Uint8Array, "Edx25519PrivateKey", 64>;
export type Edx25519Signature = FlavorP<Uint8Array, "Edx25519Signature", 64>;
/**
* 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<Edx25519PrivateKey> {
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<Edx25519PrivateKey> {
return encodeCrock(nacl.crypto_edx25519_private_key_create());
return nacl.crypto_edx25519_private_key_create();
}
export async function getPublic(
priv: Edx25519PrivateKey,
): Promise<Edx25519PublicKey> {
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<OpaqueData> {
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<Edx25519PrivateKey> {
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<Edx25519PublicKey> {
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<Uint8Array, "EncryptionNonce", 24>;
async function deriveKey(
keySeed: OpaqueData,
nonce: EncryptionNonce,
salt: string,
): Promise<Uint8Array> {
return kdfKw({
outputLength: 32,
salt: nonce,
ikm: keySeed,
info: stringToBytes(salt),
});
}
async function encryptWithDerivedKey(
nonce: EncryptionNonce,
keySeed: OpaqueData,
plaintext: OpaqueData,
salt: string,
): Promise<OpaqueData> {
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<OpaqueData> {
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<Uint8Array, "PursePublicKey", 32> &
MaterialEddsaPub;
type ContractPrivateKey = FlavorP<Uint8Array, "ContractPrivateKey", 32> &
MaterialEcdhePriv;
type MergePrivateKey = FlavorP<Uint8Array, "MergePrivateKey", 32> &
MaterialEddsaPriv;
export function encryptContractForMerge(
pursePub: PursePublicKey,
contractPriv: ContractPrivateKey,
mergePriv: MergePrivateKey,
contractTerms: any,
): Promise<OpaqueData> {
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<DecryptForMergeResult> {
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");
}

View File

@ -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<ExchangeGetContractResponse> =>
buildCodecForObject<ExchangeGetContractResponse>()
.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;
}

View File

@ -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<InitiatePeerPushPaymentRequest> =>
buildCodecForObject<InitiatePeerPushPaymentRequest>()
.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<CheckPeerPushPaymentRequest> =>
buildCodecForObject<CheckPeerPushPaymentRequest>()
.property("pursePub", codecForString())
.property("contractPriv", codecForString())
.property("exchangeBaseUrl", codecForString())
.build("CheckPeerPushPaymentRequest");
export interface AcceptPeerPushPaymentRequest {
exchangeBaseUrl: string;
pursePub: string;
}
export const codecForAcceptPeerPushPaymentRequest =
(): Codec<AcceptPeerPushPaymentRequest> =>
buildCodecForObject<AcceptPeerPushPaymentRequest>()
.property("pursePub", codecForString())
.property("exchangeBaseUrl", codecForString())
.build("AcceptPeerPushPaymentRequest");

View File

@ -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();

View File

@ -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"];

View File

@ -33,10 +33,12 @@ import {
BlindedDenominationSignature,
bufferForUint32,
buildSigPS,
bytesToString,
CoinDepositPermission,
CoinEnvelope,
createHashContext,
decodeCrock,
decryptContractForMerge,
DenomKeyType,
DepositInfo,
ecdheGetPublic,
@ -45,6 +47,7 @@ import {
eddsaSign,
eddsaVerify,
encodeCrock,
encryptContractForMerge,
ExchangeProtocolVersion,
getRandomBytes,
hash,
@ -81,10 +84,17 @@ import { DenominationRecord, WireFee } from "../db.js";
import {
CreateRecoupRefreshReqRequest,
CreateRecoupReqRequest,
DecryptContractRequest,
DecryptContractResponse,
DerivedRefreshSession,
DerivedTipPlanchet,
DeriveRefreshSessionRequest,
DeriveTipRequest,
EncryptContractRequest,
EncryptContractResponse,
EncryptedContract,
SignPurseMergeRequest,
SignPurseMergeResponse,
SignTrackTransactionRequest,
} from "./cryptoTypes.js";
@ -185,6 +195,16 @@ export interface TalerCryptoInterface {
signPurseDeposits(
req: SignPurseDepositsRequest,
): Promise<SignPurseDepositsResponse>;
encryptContractForMerge(
req: EncryptContractRequest,
): Promise<EncryptContractResponse>;
decryptContractForMerge(
req: DecryptContractRequest,
): Promise<DecryptContractResponse>;
signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>;
}
/**
@ -326,6 +346,21 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<SignPurseDepositsResponse> {
throw new Error("Function not implemented.");
},
encryptContractForMerge: function (
req: EncryptContractRequest,
): Promise<EncryptContractResponse> {
throw new Error("Function not implemented.");
},
decryptContractForMerge: function (
req: DecryptContractRequest,
): Promise<DecryptContractResponse> {
throw new Error("Function not implemented.");
},
signPurseMerge: function (
req: SignPurseMergeRequest,
): Promise<SignPurseMergeResponse> {
throw new Error("Function not implemented.");
},
};
export type WithArg<X> = X extends (req: infer T) => infer R
@ -502,6 +537,9 @@ export interface TransferPubResponse {
transferPriv: string;
}
/**
* JS-native implementation of the Taler crypto worker operations.
*/
export const nativeCryptoR: TalerCryptoInterfaceR = {
async eddsaSign(
tci: TalerCryptoInterfaceR,
@ -960,9 +998,11 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
maybeAgeCommitmentHash = ach;
hAgeCommitment = decodeCrock(ach);
if (depositInfo.requiredMinimumAge != null) {
minimumAgeSig = AgeRestriction.commitmentAttest(
depositInfo.ageCommitmentProof,
depositInfo.requiredMinimumAge,
minimumAgeSig = encodeCrock(
AgeRestriction.commitmentAttest(
depositInfo.ageCommitmentProof,
depositInfo.requiredMinimumAge,
),
);
}
} else {
@ -1094,7 +1134,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
if (req.meltCoinAgeCommitmentProof) {
newAc = await AgeRestriction.commitmentDerive(
req.meltCoinAgeCommitmentProof,
transferSecretRes.h,
decodeCrock(transferSecretRes.h),
);
newAch = AgeRestriction.hashCommitment(newAc.commitment);
}
@ -1280,6 +1320,9 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
for (const c of req.coins) {
const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT)
.put(amountToBuffer(Amounts.parseOrThrow(c.contribution)))
.put(decodeCrock(c.denomPubHash))
// FIXME: use h_age_commitment here
.put(new Uint8Array(32))
.put(decodeCrock(req.pursePub))
.put(hExchangeBaseUrl)
.build();
@ -1300,6 +1343,87 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
deposits,
};
},
async encryptContractForMerge(
tci: TalerCryptoInterfaceR,
req: EncryptContractRequest,
): Promise<EncryptContractResponse> {
const contractKeyPair = await this.createEddsaKeypair(tci, {});
const enc = await encryptContractForMerge(
decodeCrock(req.pursePub),
decodeCrock(contractKeyPair.priv),
decodeCrock(req.mergePriv),
req.contractTerms,
);
const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_ECONTRACT)
.put(hash(enc))
.put(decodeCrock(contractKeyPair.pub))
.build();
const sig = eddsaSign(sigBlob, decodeCrock(req.pursePriv));
return {
econtract: {
contract_pub: contractKeyPair.pub,
econtract: encodeCrock(enc),
econtract_sig: encodeCrock(sig),
},
contractPriv: contractKeyPair.priv,
};
},
async decryptContractForMerge(
tci: TalerCryptoInterfaceR,
req: DecryptContractRequest,
): Promise<DecryptContractResponse> {
const res = await decryptContractForMerge(
decodeCrock(req.ciphertext),
decodeCrock(req.pursePub),
decodeCrock(req.contractPriv),
);
return {
contractTerms: res.contractTerms,
mergePriv: encodeCrock(res.mergePriv),
};
},
async signPurseMerge(
tci: TalerCryptoInterfaceR,
req: SignPurseMergeRequest,
): Promise<SignPurseMergeResponse> {
const mergeSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE)
.put(timestampRoundedToBuffer(req.mergeTimestamp))
.put(decodeCrock(req.pursePub))
.put(hashTruncate32(stringToBytes(req.reservePayto + "\0")))
.build();
const mergeSigResp = await tci.eddsaSign(tci, {
msg: encodeCrock(mergeSigBlob),
priv: req.mergePriv,
});
const reserveSigBlob = buildSigPS(
TalerSignaturePurpose.WALLET_ACCOUNT_MERGE,
)
.put(timestampRoundedToBuffer(req.purseExpiration))
.put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
.put(amountToBuffer(Amounts.parseOrThrow(req.purseFee)))
.put(decodeCrock(req.contractTermsHash))
.put(decodeCrock(req.pursePub))
.put(timestampRoundedToBuffer(req.mergeTimestamp))
// FIXME: put in min_age
.put(bufferForUint32(0))
.put(bufferForUint32(req.flags))
.build();
logger.info(
`signing WALLET_ACCOUNT_MERGE over ${encodeCrock(reserveSigBlob)}`,
);
const reserveSigResp = await tci.eddsaSign(tci, {
msg: encodeCrock(reserveSigBlob),
priv: req.reservePriv,
});
return {
mergeSig: mergeSigResp.sig,
accountSig: reserveSigResp.sig,
};
},
};
function amountToBuffer(amount: AmountJson): Uint8Array {

View File

@ -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;
}
}
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;
}

View File

@ -42,6 +42,7 @@ import {
TalerProtocolDuration,
AgeCommitmentProof,
PayCoinSelection,
PeerContractTerms,
} from "@gnu-taler/taler-util";
import { RetryInfo } from "./util/retries.js";
import { Event, IDBDatabase } from "@gnu-taler/idb-bridge";
@ -561,6 +562,12 @@ export interface ExchangeRecord {
* Retry status for fetching updated information about the exchange.
*/
retryInfo?: RetryInfo;
/**
* Public key of the reserve that we're currently using for
* receiving P2P payments.
*/
currentMergeReservePub?: string;
}
/**
@ -1675,7 +1682,6 @@ export interface BalancePerCurrencyRecord {
* Record for a push P2P payment that this wallet initiated.
*/
export interface PeerPushPaymentInitiationRecord {
/**
* What exchange are funds coming from?
*/
@ -1704,18 +1710,40 @@ export interface PeerPushPaymentInitiationRecord {
*/
mergePriv: string;
contractPriv: string;
contractPub: string;
purseExpiration: TalerProtocolTimestamp;
/**
* Did we successfully create the purse with the exchange?
*/
purseCreated: boolean;
timestampCreated: TalerProtocolTimestamp;
}
/**
* Record for a push P2P payment that this wallet accepted.
* Record for a push P2P payment that this wallet was offered.
*
* Primary key: (exchangeBaseUrl, pursePub)
*/
export interface PeerPushPaymentAcceptanceRecord {}
export interface PeerPushPaymentIncomingRecord {
exchangeBaseUrl: string;
pursePub: string;
mergePriv: string;
contractPriv: string;
timestampAccepted: TalerProtocolTimestamp;
contractTerms: PeerContractTerms;
// FIXME: add status etc.
}
export const WalletStoresV1 = {
coins: describeStore(
@ -1893,6 +1921,12 @@ export const WalletStoresV1 = {
}),
{},
),
peerPushPaymentIncoming: describeStore(
describeContents<PeerPushPaymentIncomingRecord>("peerPushPaymentIncoming", {
keyPath: ["exchangeBaseUrl", "pursePub"],
}),
{},
),
};
export interface MetaConfigRecord {

View File

@ -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) {

View File

@ -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,

View File

@ -18,25 +18,47 @@
* Imports.
*/
import {
AbsoluteTime,
AcceptPeerPushPaymentRequest,
AmountJson,
Amounts,
Logger,
InitiatePeerPushPaymentResponse,
InitiatePeerPushPaymentRequest,
strcmp,
CoinPublicKeyString,
j2s,
getRandomBytes,
Duration,
durationAdd,
TalerProtocolTimestamp,
AbsoluteTime,
encodeCrock,
AmountString,
buildCodecForObject,
CheckPeerPushPaymentRequest,
CheckPeerPushPaymentResponse,
Codec,
codecForAmountString,
codecForAny,
codecForExchangeGetContractResponse,
ContractTermsUtil,
decodeCrock,
Duration,
eddsaGetPublic,
encodeCrock,
ExchangePurseMergeRequest,
InitiatePeerPushPaymentRequest,
InitiatePeerPushPaymentResponse,
j2s,
Logger,
strcmp,
TalerProtocolTimestamp,
UnblindedSignature,
WalletAccountMergeFlags,
} from "@gnu-taler/taler-util";
import { CoinStatus } from "../db.js";
import { url } from "inspector";
import {
CoinStatus,
OperationStatus,
ReserveRecord,
ReserveRecordStatus,
} from "../db.js";
import {
checkSuccessResponseOrThrow,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
} from "../util/http.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
const logger = new Logger("operations/peer-to-peer.ts");
@ -176,14 +198,22 @@ export async function initiatePeerToPeerPush(
const pursePair = await ws.cryptoApi.createEddsaKeypair({});
const mergePair = await ws.cryptoApi.createEddsaKeypair({});
const hContractTerms = encodeCrock(getRandomBytes(64));
const purseExpiration = AbsoluteTime.toTimestamp(
const purseExpiration: TalerProtocolTimestamp = AbsoluteTime.toTimestamp(
AbsoluteTime.addDuration(
AbsoluteTime.now(),
Duration.fromSpec({ days: 2 }),
),
);
const contractTerms = {
...req.partialContractTerms,
purse_expiration: purseExpiration,
amount: req.amount,
};
const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
const purseSigResp = await ws.cryptoApi.signPurseCreation({
hContractTerms,
mergePub: mergePair.pub,
@ -204,6 +234,13 @@ export async function initiatePeerToPeerPush(
coinSelRes.exchangeBaseUrl,
);
const econtractResp = await ws.cryptoApi.encryptContractForMerge({
contractTerms,
mergePriv: mergePair.priv,
pursePriv: pursePair.priv,
pursePub: pursePair.pub,
});
const httpResp = await ws.http.postJson(createPurseUrl.href, {
amount: Amounts.stringify(instructedAmount),
merge_pub: mergePair.pub,
@ -212,11 +249,216 @@ export async function initiatePeerToPeerPush(
purse_expiration: purseExpiration,
deposits: depositSigsResp.deposits,
min_age: 0,
econtract: econtractResp.econtract,
});
const resp = await httpResp.json();
logger.info(`resp: ${j2s(resp)}`);
throw Error("not yet implemented");
if (httpResp.status !== 200) {
throw Error("got error response from exchange");
}
return {
contractPriv: econtractResp.contractPriv,
mergePriv: mergePair.priv,
pursePub: pursePair.pub,
exchangeBaseUrl: coinSelRes.exchangeBaseUrl,
};
}
interface ExchangePurseStatus {
balance: AmountString;
}
export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
buildCodecForObject<ExchangePurseStatus>()
.property("balance", codecForAmountString())
.build("ExchangePurseStatus");
export async function checkPeerPushPayment(
ws: InternalWalletState,
req: CheckPeerPushPaymentRequest,
): Promise<CheckPeerPushPaymentResponse> {
const getPurseUrl = new URL(
`purses/${req.pursePub}/deposit`,
req.exchangeBaseUrl,
);
const contractPub = encodeCrock(
eddsaGetPublic(decodeCrock(req.contractPriv)),
);
const purseHttpResp = await ws.http.get(getPurseUrl.href);
const purseStatus = await readSuccessResponseJsonOrThrow(
purseHttpResp,
codecForExchangePurseStatus(),
);
const getContractUrl = new URL(
`contracts/${contractPub}`,
req.exchangeBaseUrl,
);
const contractHttpResp = await ws.http.get(getContractUrl.href);
const contractResp = await readSuccessResponseJsonOrThrow(
contractHttpResp,
codecForExchangeGetContractResponse(),
);
const dec = await ws.cryptoApi.decryptContractForMerge({
ciphertext: contractResp.econtract,
contractPriv: req.contractPriv,
pursePub: req.pursePub,
});
await ws.db
.mktx((x) => ({
peerPushPaymentIncoming: x.peerPushPaymentIncoming,
}))
.runReadWrite(async (tx) => {
await tx.peerPushPaymentIncoming.add({
contractPriv: req.contractPriv,
exchangeBaseUrl: req.exchangeBaseUrl,
mergePriv: dec.mergePriv,
pursePub: req.pursePub,
timestampAccepted: TalerProtocolTimestamp.now(),
contractTerms: dec.contractTerms,
});
});
return {
amount: purseStatus.balance,
contractTerms: dec.contractTerms,
};
}
export function talerPaytoFromExchangeReserve(
exchangeBaseUrl: string,
reservePub: string,
): string {
const url = new URL(exchangeBaseUrl);
let proto: string;
if (url.protocol === "http:") {
proto = "taler+http";
} else if (url.protocol === "https:") {
proto = "taler";
} else {
throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
}
let path = url.pathname;
if (!path.endsWith("/")) {
path = path + "/";
}
return `payto://${proto}/${url.host}${url.pathname}${reservePub}`;
}
export async function acceptPeerPushPayment(
ws: InternalWalletState,
req: AcceptPeerPushPaymentRequest,
) {
const peerInc = await ws.db
.mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming }))
.runReadOnly(async (tx) => {
return tx.peerPushPaymentIncoming.get([
req.exchangeBaseUrl,
req.pursePub,
]);
});
if (!peerInc) {
throw Error("can't accept unknown incoming p2p push payment");
}
const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount);
// We have to create the key pair outside of the transaction,
// due to the async crypto API.
const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
const reserve: ReserveRecord | undefined = await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const ex = await tx.exchanges.get(req.exchangeBaseUrl);
checkDbInvariant(!!ex);
if (ex.currentMergeReservePub) {
return await tx.reserves.get(ex.currentMergeReservePub);
}
const rec: ReserveRecord = {
exchangeBaseUrl: req.exchangeBaseUrl,
// FIXME: field will be removed in the future, folded into withdrawal/p2p record.
reserveStatus: ReserveRecordStatus.Dormant,
timestampCreated: TalerProtocolTimestamp.now(),
instructedAmount: Amounts.getZero(amount.currency),
currency: amount.currency,
reservePub: newReservePair.pub,
reservePriv: newReservePair.priv,
timestampBankConfirmed: undefined,
timestampReserveInfoPosted: undefined,
// FIXME!
initialDenomSel: undefined as any,
// FIXME!
initialWithdrawalGroupId: "",
initialWithdrawalStarted: false,
lastError: undefined,
operationStatus: OperationStatus.Pending,
retryInfo: undefined,
bankInfo: undefined,
restrictAge: undefined,
senderWire: undefined,
};
await tx.reserves.put(rec);
return rec;
});
if (!reserve) {
throw Error("can't create reserve");
}
const mergeTimestamp = TalerProtocolTimestamp.now();
const reservePayto = talerPaytoFromExchangeReserve(
reserve.exchangeBaseUrl,
reserve.reservePub,
);
const sigRes = await ws.cryptoApi.signPurseMerge({
contractTermsHash: ContractTermsUtil.hashContractTerms(
peerInc.contractTerms,
),
flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
mergePriv: peerInc.mergePriv,
mergeTimestamp: mergeTimestamp,
purseAmount: Amounts.stringify(amount),
purseExpiration: peerInc.contractTerms.purse_expiration,
purseFee: Amounts.stringify(Amounts.getZero(amount.currency)),
pursePub: peerInc.pursePub,
reservePayto,
reservePriv: reserve.reservePriv,
});
const mergePurseUrl = new URL(
`purses/${req.pursePub}/merge`,
req.exchangeBaseUrl,
);
const mergeReq: ExchangePurseMergeRequest = {
payto_uri: reservePayto,
merge_timestamp: mergeTimestamp,
merge_sig: sigRes.mergeSig,
reserve_sig: sigRes.accountSig,
};
const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq);
const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, codecForAny());
logger.info(`merge result: ${j2s(res)}`);
}

View File

@ -1,122 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Imports.
*/
import test from "ava";
import { ContractTermsUtil } from "./contractTerms.js";
test("contract terms canon hashing", (t) => {
const cReq = {
foo: 42,
bar: "hello",
$forgettable: {
foo: true,
},
};
const c1 = ContractTermsUtil.saltForgettable(cReq);
const c2 = ContractTermsUtil.saltForgettable(cReq);
t.assert(typeof cReq.$forgettable.foo === "boolean");
t.assert(typeof c1.$forgettable.foo === "string");
t.assert(c1.$forgettable.foo !== c2.$forgettable.foo);
const h1 = ContractTermsUtil.hashContractTerms(c1);
const c3 = ContractTermsUtil.scrub(JSON.parse(JSON.stringify(c1)));
t.assert(c3.foo === undefined);
t.assert(c3.bar === cReq.bar);
const h2 = ContractTermsUtil.hashContractTerms(c3);
t.deepEqual(h1, h2);
});
test("contract terms canon hashing (nested)", (t) => {
const cReq = {
foo: 42,
bar: {
prop1: "hello, world",
$forgettable: {
prop1: true,
},
},
$forgettable: {
bar: true,
},
};
const c1 = ContractTermsUtil.saltForgettable(cReq);
t.is(typeof c1.$forgettable.bar, "string");
t.is(typeof c1.bar.$forgettable.prop1, "string");
const forgetPath = (x: any, s: string) =>
ContractTermsUtil.forgetAll(x, (p) => p.join(".") === s);
// Forget bar first
const c2 = forgetPath(c1, "bar");
// Forget bar.prop1 first
const c3 = forgetPath(forgetPath(c1, "bar.prop1"), "bar");
// Forget everything
const c4 = ContractTermsUtil.scrub(c1);
const h1 = ContractTermsUtil.hashContractTerms(c1);
const h2 = ContractTermsUtil.hashContractTerms(c2);
const h3 = ContractTermsUtil.hashContractTerms(c3);
const h4 = ContractTermsUtil.hashContractTerms(c4);
t.is(h1, h2);
t.is(h1, h3);
t.is(h1, h4);
// Doesn't contain salt
t.false(ContractTermsUtil.validateForgettable(cReq));
t.true(ContractTermsUtil.validateForgettable(c1));
t.true(ContractTermsUtil.validateForgettable(c2));
t.true(ContractTermsUtil.validateForgettable(c3));
t.true(ContractTermsUtil.validateForgettable(c4));
});
test("contract terms reference vector", (t) => {
const j = {
k1: 1,
$forgettable: {
k1: "SALT",
},
k2: {
n1: true,
$forgettable: {
n1: "salt",
},
},
k3: {
n1: "string",
},
};
const h = ContractTermsUtil.hashContractTerms(j);
t.deepEqual(
h,
"VDE8JPX0AEEE3EX1K8E11RYEWSZQKGGZCV6BWTE4ST1C8711P7H850Z7F2Q2HSSYETX87ERC2JNHWB7GTDWTDWMM716VKPSRBXD7SRR",
);
});

View File

@ -1,230 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { canonicalJson, Logger } from "@gnu-taler/taler-util";
import { kdf } from "@gnu-taler/taler-util";
import {
decodeCrock,
encodeCrock,
getRandomBytes,
hash,
stringToBytes,
} from "@gnu-taler/taler-util";
const logger = new Logger("contractTerms.ts");
export namespace ContractTermsUtil {
export type PathPredicate = (path: string[]) => boolean;
/**
* Scrub all forgettable members from an object.
*/
export function scrub(anyJson: any): any {
return forgetAllImpl(anyJson, [], () => true);
}
/**
* Recursively forget all forgettable members of an object,
* where the path matches a predicate.
*/
export function forgetAll(anyJson: any, pred: PathPredicate): any {
return forgetAllImpl(anyJson, [], pred);
}
function forgetAllImpl(
anyJson: any,
path: string[],
pred: PathPredicate,
): any {
const dup = JSON.parse(JSON.stringify(anyJson));
if (Array.isArray(dup)) {
for (let i = 0; i < dup.length; i++) {
dup[i] = forgetAllImpl(dup[i], [...path, `${i}`], pred);
}
} else if (typeof dup === "object" && dup != null) {
if (typeof dup.$forgettable === "object") {
for (const x of Object.keys(dup.$forgettable)) {
if (!pred([...path, x])) {
continue;
}
if (!dup.$forgotten) {
dup.$forgotten = {};
}
if (!dup.$forgotten[x]) {
const membValCanon = stringToBytes(
canonicalJson(scrub(dup[x])) + "\0",
);
const membSalt = stringToBytes(dup.$forgettable[x] + "\0");
const h = kdf(64, membValCanon, membSalt, new Uint8Array([]));
dup.$forgotten[x] = encodeCrock(h);
}
delete dup[x];
delete dup.$forgettable[x];
}
if (Object.keys(dup.$forgettable).length === 0) {
delete dup.$forgettable;
}
}
for (const x of Object.keys(dup)) {
if (x.startsWith("$")) {
continue;
}
dup[x] = forgetAllImpl(dup[x], [...path, x], pred);
}
}
return dup;
}
/**
* Generate a salt for all members marked as forgettable,
* but which don't have an actual salt yet.
*/
export function saltForgettable(anyJson: any): any {
const dup = JSON.parse(JSON.stringify(anyJson));
if (Array.isArray(dup)) {
for (let i = 0; i < dup.length; i++) {
dup[i] = saltForgettable(dup[i]);
}
} else if (typeof dup === "object" && dup !== null) {
if (typeof dup.$forgettable === "object") {
for (const k of Object.keys(dup.$forgettable)) {
if (dup.$forgettable[k] === true) {
dup.$forgettable[k] = encodeCrock(getRandomBytes(32));
}
}
}
for (const x of Object.keys(dup)) {
if (x.startsWith("$")) {
continue;
}
dup[x] = saltForgettable(dup[x]);
}
}
return dup;
}
const nameRegex = /^[0-9A-Za-z_]+$/;
/**
* Check that the given JSON object is well-formed with regards
* to forgettable fields and other restrictions for forgettable JSON.
*/
export function validateForgettable(anyJson: any): boolean {
if (typeof anyJson === "string") {
return true;
}
if (typeof anyJson === "number") {
return (
Number.isInteger(anyJson) &&
anyJson >= Number.MIN_SAFE_INTEGER &&
anyJson <= Number.MAX_SAFE_INTEGER
);
}
if (typeof anyJson === "boolean") {
return true;
}
if (anyJson === null) {
return true;
}
if (Array.isArray(anyJson)) {
return anyJson.every((x) => validateForgettable(x));
}
if (typeof anyJson === "object") {
for (const k of Object.keys(anyJson)) {
if (k.match(nameRegex)) {
if (validateForgettable(anyJson[k])) {
continue;
} else {
return false;
}
}
if (k === "$forgettable") {
const fga = anyJson.$forgettable;
if (!fga || typeof fga !== "object") {
return false;
}
for (const fk of Object.keys(fga)) {
if (!fk.match(nameRegex)) {
return false;
}
if (!(fk in anyJson)) {
return false;
}
const fv = anyJson.$forgettable[fk];
if (typeof fv !== "string") {
return false;
}
}
} else if (k === "$forgotten") {
const fgo = anyJson.$forgotten;
if (!fgo || typeof fgo !== "object") {
return false;
}
for (const fk of Object.keys(fgo)) {
if (!fk.match(nameRegex)) {
return false;
}
// Check that the value has actually been forgotten.
if (fk in anyJson) {
return false;
}
const fv = anyJson.$forgotten[fk];
if (typeof fv !== "string") {
return false;
}
try {
const decFv = decodeCrock(fv);
if (decFv.length != 64) {
return false;
}
} catch (e) {
return false;
}
// Check that salt has been deleted after forgetting.
if (anyJson.$forgettable?.[k] !== undefined) {
return false;
}
}
} else {
return false;
}
}
return true;
}
return false;
}
/**
* Check that no forgettable information has been forgotten.
*
* Must only be called on an object already validated with validateForgettable.
*/
export function validateNothingForgotten(contractTerms: any): boolean {
throw Error("not implemented yet");
}
/**
* Hash a contract terms object. Forgettable fields
* are scrubbed and JSON canonicalization is applied
* before hashing.
*/
export function hashContractTerms(contractTerms: unknown): string {
const cleaned = scrub(contractTerms);
const canon = canonicalJson(cleaned) + "\0";
const bytes = stringToBytes(canon);
return encodeCrock(hash(bytes));
}
}

View File

@ -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<

View File

@ -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,

View File

@ -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: