From a165afa6824980c409d7c2e22e24171e536800e0 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 19 Apr 2022 17:12:43 +0200 Subject: wallet-core: implement age restriction support --- packages/taler-util/src/nacl-fast.ts | 33 +-- packages/taler-util/src/talerCrypto.test.ts | 86 +++++++- packages/taler-util/src/talerCrypto.ts | 236 +++++++++++++++++++-- packages/taler-util/src/talerTypes.ts | 20 +- packages/taler-util/src/walletTypes.ts | 15 ++ .../src/harness/denomStructures.ts | 1 + packages/taler-wallet-cli/src/harness/harness.ts | 27 ++- packages/taler-wallet-cli/src/harness/helpers.ts | 24 ++- .../src/integrationtests/test-age-restrictions.ts | 64 ++++++ .../src/integrationtests/testrunner.ts | 2 + .../src/crypto/cryptoImplementation.ts | 76 ++++++- .../taler-wallet-core/src/crypto/cryptoTypes.ts | 2 + .../src/crypto/workers/cryptoDispatcher.ts | 4 +- packages/taler-wallet-core/src/db.ts | 15 ++ .../taler-wallet-core/src/operations/exchanges.ts | 2 + packages/taler-wallet-core/src/operations/pay.ts | 19 ++ .../taler-wallet-core/src/operations/refresh.ts | 24 +++ .../taler-wallet-core/src/operations/reserves.ts | 21 +- .../src/operations/withdraw.test.ts | 6 + .../taler-wallet-core/src/operations/withdraw.ts | 15 +- .../src/util/coinSelection.test.ts | 1 + packages/taler-wallet-core/src/wallet.ts | 3 +- 22 files changed, 630 insertions(+), 66 deletions(-) create mode 100644 packages/taler-wallet-cli/src/integrationtests/test-age-restrictions.ts diff --git a/packages/taler-util/src/nacl-fast.ts b/packages/taler-util/src/nacl-fast.ts index 82bdc7cec..c45674bef 100644 --- a/packages/taler-util/src/nacl-fast.ts +++ b/packages/taler-util/src/nacl-fast.ts @@ -1769,7 +1769,7 @@ function crypto_scalarmult_base(q: Uint8Array, n: Uint8Array): number { return crypto_scalarmult(q, n, _9); } -function crypto_scalarmult_noclamp( +export function crypto_scalarmult_noclamp( q: Uint8Array, n: Uint8Array, p: Uint8Array, @@ -3033,6 +3033,18 @@ export function crypto_core_ed25519_scalar_add( return o; } +/** + * Reduce a scalar "s" to "s mod L". The input can be up to 64 bytes long. + */ +export function crypto_core_ed25519_scalar_reduce(x: Uint8Array): Uint8Array { + const len = x.length; + const z = new Float64Array(64); + for (let i = 0; i < len; i++) z[i] = x[i]; + const o = new Uint8Array(32); + modL(o, z); + return o; +} + export function crypto_core_ed25519_scalar_sub( x: Uint8Array, y: Uint8Array, @@ -3063,11 +3075,7 @@ export function crypto_edx25519_private_key_create_from_seed( } export function crypto_edx25519_get_public(priv: Uint8Array): Uint8Array { - const pub = new Uint8Array(32); - if (0 != crypto_scalarmult_base_noclamp(pub.subarray(32), priv)) { - throw Error(); - } - return pub; + return crypto_scalarmult_ed25519_base_noclamp(priv.subarray(0, 32)); } export function crypto_edx25519_sign_detached( @@ -3076,19 +3084,16 @@ export function crypto_edx25519_sign_detached( pkx: Uint8Array, ): Uint8Array { const n: number = m.length; - const d = new Uint8Array(64), - h = new Uint8Array(64), - r = new Uint8Array(64); + const h = new Uint8Array(64); + const r = new Uint8Array(64); let i, j; const x = new Float64Array(64); const p = [gf(), gf(), gf(), gf()]; - for (i = 0; i < 64; i++) d[i] = skx[i]; - const sm = new Uint8Array(n + 64); for (i = 0; i < n; i++) sm[64 + i] = m[i]; - for (i = 0; i < 32; i++) sm[32 + i] = d[32 + i]; + for (i = 0; i < 32; i++) sm[32 + i] = skx[32 + i]; crypto_hash(r, sm.subarray(32), n + 32); reduce(r); @@ -3103,12 +3108,12 @@ export function crypto_edx25519_sign_detached( for (i = 0; i < 32; i++) x[i] = r[i]; for (i = 0; i < 32; i++) { for (j = 0; j < 32; j++) { - x[i + j] += h[i] * d[j]; + x[i + j] += h[i] * skx[j]; } } modL(sm.subarray(32), x); - return sm.subarray(64); + return sm.subarray(0, 64); } export function crypto_edx25519_sign_detached_verify( diff --git a/packages/taler-util/src/talerCrypto.test.ts b/packages/taler-util/src/talerCrypto.test.ts index 70ad8a614..5e8f37d80 100644 --- a/packages/taler-util/src/talerCrypto.test.ts +++ b/packages/taler-util/src/talerCrypto.test.ts @@ -34,6 +34,10 @@ import { scalarMultBase25519, deriveSecrets, calcRBlind, + Edx25519, + getRandomBytes, + bigintToNaclArr, + bigintFromNaclArr, } from "./talerCrypto.js"; import { sha512, kdf } from "./kdf.js"; import * as nacl from "./nacl-fast.js"; @@ -44,6 +48,7 @@ import { initNodePrng } from "./prng-node.js"; initNodePrng(); import bigint from "big-integer"; import { AssertionError } from "assert"; +import BigInteger from "big-integer"; test("encoding", (t) => { const s = "Hello, World"; @@ -343,9 +348,86 @@ test("taler CS blind c", async (t) => { }; const sig = await csUnblind(bseed, rPub, pub, b, blindsig); - t.deepEqual(sig.s, decodeCrock("F4ZKMFW3Q7DFN0N94KAMG2JFFHAC362T0QZ6ZCVZ73RS8P91CR70")); - t.deepEqual(sig.rPub, decodeCrock("CHK7JC4SXZ4Y9RDA3881S82F7BP99H35Q361WR6RBXN5YN2ZM1M0")); + t.deepEqual( + sig.s, + decodeCrock("F4ZKMFW3Q7DFN0N94KAMG2JFFHAC362T0QZ6ZCVZ73RS8P91CR70"), + ); + t.deepEqual( + sig.rPub, + decodeCrock("CHK7JC4SXZ4Y9RDA3881S82F7BP99H35Q361WR6RBXN5YN2ZM1M0"), + ); const res = await csVerify(decodeCrock(msg_hash), sig, pub); t.deepEqual(res, true); }); + +test("bigint/nacl conversion", async (t) => { + const b1 = BigInteger(42); + const n1 = bigintToNaclArr(b1, 32); + t.is(n1[0], 42); + t.is(n1.length, 32); + const b2 = bigintFromNaclArr(n1); + t.true(b1.eq(b2)); +}); + +test("taler age restriction crypto", async (t) => { + const priv1 = await Edx25519.keyCreate(); + const pub1 = await Edx25519.getPublic(priv1); + + const seed = encodeCrock(getRandomBytes(32)); + + const priv2 = await Edx25519.privateKeyDerive(priv1, seed); + const pub2 = await Edx25519.publicKeyDerive(pub1, seed); + + const pub2Ref = await Edx25519.getPublic(priv2); + + t.is(pub2, pub2Ref); +}); + +test("edx signing", async (t) => { + const priv1 = await Edx25519.keyCreate(); + const pub1 = await Edx25519.getPublic(priv1); + + const msg = stringToBytes("hello world"); + + const sig = nacl.crypto_edx25519_sign_detached( + msg, + decodeCrock(priv1), + decodeCrock(pub1), + ); + + t.true( + nacl.crypto_edx25519_sign_detached_verify(msg, sig, decodeCrock(pub1)), + ); + + sig[0]++; + + t.false( + nacl.crypto_edx25519_sign_detached_verify(msg, sig, decodeCrock(pub1)), + ); +}); + +test("edx test vector", async (t) => { + // Generated by gnunet-crypto-tvg + const tv = { + operation: "edx25519_derive", + priv1_edx: + "216KF1XM46K4JN8TX3Z8HNRX1DX4WRMX1BTCQM3KBS83PYKFY1GV6XRNBYRC5YM02HVDX8BDR20V7A27YX4MZJ8X8K0ADPZ43BD1GXG", + pub1_edx: "RKGRRG74SZ8PKF8SYG5SSDY8VRCYYGY5N2AKAJCG0103Z3JK6HTG", + seed: "EFK7CYT98YWGPNZNHPP84VJZDMXD5A41PP3E94NSAQZXRCAKVVXHAQNXG9XM2MAND2FJ56ZM238KGDCF3B0KCWNZCYKKHKDB56X6QA0", + priv2_edx: + "JRV3S06REHQV90E4HJA1FAMCVDBZZAZP9C6N2WF01MSR3CD5KM28QM7HTGGAV6MBJZ73QJ8PSZFA0D6YENJ7YT97344FDVVCGVAFNER", + pub2_edx: "ZB546ZC7ZP16DB99AMK67WNZ67WZFPWMRY67Y4PZR9YR1D82GVZ0", + }; + + { + const pub1Prime = await Edx25519.getPublic(tv.priv1_edx); + t.is(pub1Prime, tv.pub1_edx); + } + + const pub2Prime = await Edx25519.publicKeyDerive(tv.pub1_edx, tv.seed); + t.is(pub2Prime, tv.pub2_edx); + + const priv2Prime = await Edx25519.privateKeyDerive(tv.priv1_edx, tv.seed); + t.is(priv2Prime, tv.priv2_edx); +}); diff --git a/packages/taler-util/src/talerCrypto.ts b/packages/taler-util/src/talerCrypto.ts index 282d22d8b..228dc3269 100644 --- a/packages/taler-util/src/talerCrypto.ts +++ b/packages/taler-util/src/talerCrypto.ts @@ -27,6 +27,7 @@ import bigint from "big-integer"; import { Base32String, CoinEnvelope, + CoinPublicKeyString, DenominationPubKey, DenomKeyType, HashCodeString, @@ -643,6 +644,17 @@ export function hashCoinEvInner( } } +export function hashCoinPub( + coinPub: CoinPublicKeyString, + ach?: HashCodeString, +): Uint8Array { + if (!ach) { + return hash(decodeCrock(coinPub)); + } + + return hash(typedArrayConcat([decodeCrock(coinPub), decodeCrock(ach)])); +} + /** * Hash a denomination public key. */ @@ -652,6 +664,7 @@ export function hashDenomPub(pub: DenominationPubKey): Uint8Array { const hashInputBuf = new ArrayBuffer(pubBuf.length + 4 + 4); const uint8ArrayBuf = new Uint8Array(hashInputBuf); const dv = new DataView(hashInputBuf); + logger.info("age_mask", pub.age_mask); dv.setUint32(0, pub.age_mask ?? 0); dv.setUint32(4, DenomKeyType.toIntTag(pub.cipher)); uint8ArrayBuf.set(pubBuf, 8); @@ -705,6 +718,14 @@ export function bufferForUint32(n: number): Uint8Array { return buf; } +export function bufferForUint8(n: number): Uint8Array { + const arrBuf = new ArrayBuffer(1); + const buf = new Uint8Array(arrBuf); + const dv = new DataView(arrBuf); + dv.setUint8(0, n); + return buf; +} + export function setupTipPlanchet( secretSeed: Uint8Array, coinNumber: number, @@ -753,6 +774,7 @@ export enum TalerSignaturePurpose { WALLET_COIN_RECOUP = 1203, WALLET_COIN_LINK = 1204, WALLET_COIN_RECOUP_REFRESH = 1206, + WALLET_AGE_ATTESTATION = 1207, EXCHANGE_CONFIRM_RECOUP = 1039, EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041, ANASTASIS_POLICY_UPLOAD = 1400, @@ -807,6 +829,25 @@ export type Edx25519PublicKey = FlavorP; export type Edx25519PrivateKey = FlavorP; export type Edx25519Signature = FlavorP; +/** + * Convert a big integer to a fixed-size, little-endian array. + */ +export function bigintToNaclArr( + x: bigint.BigInteger, + size: number, +): Uint8Array { + const byteArr = new Uint8Array(size); + const arr = x.toArray(256).value.reverse(); + byteArr.set(arr, 0); + return byteArr; +} + +export function bigintFromNaclArr(arr: Uint8Array): bigint.BigInteger { + let rev = new Uint8Array(arr); + rev = rev.reverse(); + return bigint.fromArray(Array.from(rev), 256, false); +} + export namespace Edx25519 { const revL = [ 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, @@ -846,9 +887,9 @@ export namespace Edx25519 { ): Promise { const res = kdfKw({ outputLength: 64, - salt: stringToBytes("edx2559-derivation"), + salt: decodeCrock(seed), ikm: decodeCrock(pub), - info: decodeCrock(seed), + info: stringToBytes("edx2559-derivation"), }); return encodeCrock(res); @@ -860,28 +901,191 @@ export namespace Edx25519 { ): Promise { const pub = await getPublic(priv); const privDec = decodeCrock(priv); - const privA = privDec.subarray(0, 32).reverse(); - const a = bigint.fromArray(Array.from(privA), 256, false); + const a = bigintFromNaclArr(privDec.subarray(0, 32)); + const factorEnc = await deriveFactor(pub, seed); + const factorModL = bigintFromNaclArr(decodeCrock(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)]), + ) + .subarray(0, 32); + + const newPriv = encodeCrock( + typedArrayConcat([bigintToNaclArr(aPrime, 32), bPrime]), + ); + + return newPriv; + } - const factorBuf = await deriveFactor(pub, seed); + export async function publicKeyDerive( + pub: Edx25519PublicKey, + 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 factor = bigint.fromArray(Array.from(factorBuf), 256, false); +export interface AgeCommitment { + mask: number; - const aPrime = a.divide(8).multiply(factor).multiply(8); + /** + * Public keys, one for each age group specified in the age mask. + */ + publicKeys: Edx25519PublicKey[]; +} - const bPrime = nacl.hash( - typedArrayConcat([privDec.subarray(32, 64), decodeCrock(factorBuf)]), - ); +export interface AgeProof { + /** + * Private keys. Typically smaller than the number of public keys, + * because we drop private keys from age groups that are restricted. + */ + privateKeys: Edx25519PrivateKey[]; +} - Uint8Array.from(aPrime.toArray(256).value) +export interface AgeCommitmentProof { + commitment: AgeCommitment; + proof: AgeProof; +} +function invariant(cond: boolean): asserts cond { + if (!cond) { + throw Error("invariant failed"); + } +} + +export namespace AgeRestriction { + export function hashCommitment(ac: AgeCommitment): HashCodeString { + const hc = new nacl.HashState(); + for (const pub of ac.publicKeys) { + hc.update(decodeCrock(pub)); + } + return encodeCrock(hc.finish().subarray(0, 32)); + } + + export function countAgeGroups(mask: number): number { + let count = 0; + let m = mask; + while (m > 0) { + count += m & 1; + m = m >> 1; + } + return count; + } + + export function getAgeGroupIndex(mask: number, age: number): number { + invariant((mask & 1) === 1); + let i = 0; + let m = mask; + let a = age; + while (m > 0) { + if (a <= 0) { + break; + } + m = m >> 1; + i += m & 1; + a--; + } + return i; + } + + export function ageGroupSpecToMask(ageGroupSpec: string): number { throw Error("not implemented"); } - export function publicKeyDerive( - priv: Edx25519PrivateKey, - seed: OpaqueData, - ): Promise { - throw Error("not implemented") + export async function restrictionCommit( + ageMask: number, + age: number, + ): Promise { + invariant((ageMask & 1) === 1); + const numPubs = countAgeGroups(ageMask) - 1; + const numPrivs = getAgeGroupIndex(ageMask, age); + + const pubs: Edx25519PublicKey[] = []; + const privs: Edx25519PrivateKey[] = []; + + for (let i = 0; i < numPubs; i++) { + const priv = await Edx25519.keyCreate(); + const pub = await Edx25519.getPublic(priv); + pubs.push(pub); + if (i < numPrivs) { + privs.push(priv); + } + } + + return { + commitment: { + mask: ageMask, + publicKeys: pubs, + }, + proof: { + privateKeys: privs, + }, + }; + } + + export async function commitmentDerive( + commitmentProof: AgeCommitmentProof, + salt: OpaqueData, + ): Promise { + const newPrivs: Edx25519PrivateKey[] = []; + const newPubs: Edx25519PublicKey[] = []; + + for (const oldPub of commitmentProof.commitment.publicKeys) { + newPubs.push(await Edx25519.publicKeyDerive(oldPub, salt)); + } + + for (const oldPriv of commitmentProof.proof.privateKeys) { + newPrivs.push(await Edx25519.privateKeyDerive(oldPriv, salt)); + } + + return { + commitment: { + mask: commitmentProof.commitment.mask, + publicKeys: newPubs, + }, + proof: { + privateKeys: newPrivs, + }, + }; + } + + export function commitmentAttest( + commitmentProof: AgeCommitmentProof, + age: number, + ): Edx25519Signature { + const d = buildSigPS(TalerSignaturePurpose.WALLET_AGE_ATTESTATION) + .put(bufferForUint32(commitmentProof.commitment.mask)) + .put(bufferForUint32(age)) + .build(); + const group = getAgeGroupIndex(commitmentProof.commitment.mask, age); + if (group === 0) { + // No attestation required. + return encodeCrock(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); + } + + export function commitmentVerify( + commitmentProof: AgeCommitmentProof, + age: number, + ): Edx25519Signature { + throw Error("not implemented"); } } diff --git a/packages/taler-util/src/talerTypes.ts b/packages/taler-util/src/talerTypes.ts index b1bf6ab38..abac1cd12 100644 --- a/packages/taler-util/src/talerTypes.ts +++ b/packages/taler-util/src/talerTypes.ts @@ -47,6 +47,7 @@ import { } from "./time.js"; import { codecForAmountString } from "./amounts.js"; import { strcmp } from "./helpers.js"; +import { Edx25519PublicKey } from "./talerCrypto.js"; /** * Denomination as found in the /keys response from the exchange. @@ -283,6 +284,10 @@ export interface CoinDepositPermission { * URL of the exchange this coin was withdrawn from. */ exchange_url: string; + + minimum_age_sig?: EddsaSignatureString; + + age_commitment?: Edx25519PublicKey[]; } /** @@ -539,6 +544,8 @@ export interface ContractTerms { */ max_wire_fee?: string; + minimum_age?: number; + /** * Extra data, interpreted by the mechant only. */ @@ -957,6 +964,7 @@ export interface ExchangeMeltRequest { denom_sig: UnblindedSignature; rc: string; value_with_fee: AmountString; + age_commitment_hash?: HashCodeString; } export interface ExchangeMeltResponse { @@ -1122,7 +1130,7 @@ export type DenominationPubKey = RsaDenominationPubKey | CsDenominationPubKey; export interface RsaDenominationPubKey { readonly cipher: DenomKeyType.Rsa; readonly rsa_public_key: string; - readonly age_mask?: number; + readonly age_mask: number; } export interface CsDenominationPubKey { @@ -1177,12 +1185,14 @@ export const codecForRsaDenominationPubKey = () => buildCodecForObject() .property("cipher", codecForConstString(DenomKeyType.Rsa)) .property("rsa_public_key", codecForString()) + .property("age_mask", codecForNumber()) .build("DenominationPubKey"); export const codecForCsDenominationPubKey = () => buildCodecForObject() .property("cipher", codecForConstString(DenomKeyType.ClauseSchnorr)) .property("cs_public_key", codecForString()) + .property("age_mask", codecForNumber()) .build("CsDenominationPubKey"); export const codecForBankWithdrawalOperationPostResponse = @@ -1312,6 +1322,7 @@ export const codecForContractTerms = (): Codec => .property("exchanges", codecForList(codecForExchangeHandle())) .property("products", codecOptional(codecForList(codecForProduct()))) .property("extra", codecForAny()) + .property("minimum_age", codecOptional(codecForNumber())) .build("ContractTerms"); export const codecForMerchantRefundPermission = @@ -1717,6 +1728,13 @@ export interface ExchangeRefreshRevealRequest { transfer_pub: EddsaPublicKeyString; link_sigs: EddsaSignatureString[]; + + /** + * Iff the corresponding denomination has support for age restriction, + * the client MUST provide the original age commitment, i.e. the vector + * of public keys. + */ + old_age_commitment?: Edx25519PublicKey[]; } export interface DepositSuccess { diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index 818ba37fe..e094bc385 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -47,6 +47,7 @@ import { codecForConstString, codecForAny, buildCodecForUnion, + codecForNumber, } from "./codec.js"; import { AmountString, @@ -61,6 +62,7 @@ import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes.js"; import { BackupRecovery } from "./backupTypes.js"; import { PaytoUri } from "./payto.js"; import { TalerErrorCode } from "./taler-error-codes.js"; +import { AgeCommitmentProof } from "./talerCrypto.js"; /** * Response for the create reserve request to the wallet. @@ -218,6 +220,8 @@ export interface CreateReserveRequest { * from this reserve, only used for testing. */ forcedDenomSel?: ForcedDenomSel; + + restrictAge?: number; } export const codecForCreateReserveRequest = (): Codec => @@ -489,6 +493,7 @@ export interface WithdrawalPlanchet { coinEv: CoinEnvelope; coinValue: AmountJson; coinEvHash: string; + ageCommitmentProof?: AgeCommitmentProof; } export interface PlanchetCreationRequest { @@ -499,6 +504,7 @@ export interface PlanchetCreationRequest { denomPub: DenominationPubKey; reservePub: string; reservePriv: string; + restrictAge?: number; } /** @@ -545,6 +551,10 @@ export interface DepositInfo { denomKeyType: DenomKeyType; denomPubHash: string; denomSig: UnblindedSignature; + + requiredMinimumAge?: number; + + ageCommitmentProof?: AgeCommitmentProof; } export interface ExchangesListRespose { @@ -728,12 +738,14 @@ export const codecForAcceptManualWithdrawalRequet = export interface GetWithdrawalDetailsForAmountRequest { exchangeBaseUrl: string; amount: string; + restrictAge?: number; } export interface AcceptBankIntegratedWithdrawalRequest { talerWithdrawUri: string; exchangeBaseUrl: string; forcedDenomSel?: ForcedDenomSel; + restrictAge?: number; } export const codecForAcceptBankIntegratedWithdrawalRequest = @@ -742,6 +754,7 @@ export const codecForAcceptBankIntegratedWithdrawalRequest = .property("exchangeBaseUrl", codecForString()) .property("talerWithdrawUri", codecForString()) .property("forcedDenomSel", codecForAny()) + .property("restrictAge", codecOptional(codecForNumber())) .build("AcceptBankIntegratedWithdrawalRequest"); export const codecForGetWithdrawalDetailsForAmountRequest = @@ -774,11 +787,13 @@ export const codecForApplyRefundRequest = (): Codec => export interface GetWithdrawalDetailsForUriRequest { talerWithdrawUri: string; + restrictAge?: number; } export const codecForGetWithdrawalDetailsForUri = (): Codec => buildCodecForObject() .property("talerWithdrawUri", codecForString()) + .property("restrictAge", codecOptional(codecForNumber())) .build("GetWithdrawalDetailsForUriRequest"); export interface ListKnownBankAccountsRequest { diff --git a/packages/taler-wallet-cli/src/harness/denomStructures.ts b/packages/taler-wallet-cli/src/harness/denomStructures.ts index 2ca777030..b12857c7e 100644 --- a/packages/taler-wallet-cli/src/harness/denomStructures.ts +++ b/packages/taler-wallet-cli/src/harness/denomStructures.ts @@ -24,6 +24,7 @@ export interface CoinCoinfigCommon { feeDeposit: string; feeRefresh: string; feeRefund: string; + ageRestricted?: boolean; } export interface CoinConfigRsa extends CoinCoinfigCommon { diff --git a/packages/taler-wallet-cli/src/harness/harness.ts b/packages/taler-wallet-cli/src/harness/harness.ts index 30503e488..a2339e5f3 100644 --- a/packages/taler-wallet-cli/src/harness/harness.ts +++ b/packages/taler-wallet-cli/src/harness/harness.ts @@ -430,6 +430,9 @@ function setCoin(config: Configuration, c: CoinConfig) { config.setString(s, "fee_withdraw", c.feeWithdraw); config.setString(s, "fee_refresh", c.feeRefresh); config.setString(s, "fee_refund", c.feeRefund); + if (c.ageRestricted) { + config.setString(s, "age_restricted", "yes"); + } if (c.cipher === "RSA") { config.setString(s, "rsa_keysize", `${c.rsaKeySize}`); config.setString(s, "cipher", "RSA"); @@ -1112,6 +1115,17 @@ export class ExchangeService implements ExchangeServiceInterface { config.write(this.configFilename); } + enableAgeRestrictions(maskStr: string) { + const config = Configuration.load(this.configFilename); + config.setString("exchange-extension-age_restriction", "enabled", "yes"); + config.setString( + "exchange-extension-age_restriction", + "age_groups", + maskStr, + ); + config.write(this.configFilename); + } + get masterPub() { return encodeCrock(this.keyPair.eddsaPub); } @@ -1645,8 +1659,14 @@ export class MerchantService implements MerchantServiceInterface { await exec(`taler-merchant-dbinit -c "${this.configFilename}"`); this.proc = this.globalState.spawnService( - "taler-merchant-httpd", - ["-LDEBUG", "-c", this.configFilename, ...this.timetravelArgArr], + "valgrind", + [ + "taler-merchant-httpd", + "-LDEBUG", + "-c", + this.configFilename, + ...this.timetravelArgArr, + ], `merchant-${this.merchantConfig.name}`, ); } @@ -1848,6 +1868,9 @@ export async function runTestWithState( } } catch (e) { console.error("FATAL: test failed with exception", e); + if (e instanceof TalerError) { + console.error(`error detail: ${j2s(e.errorDetail)}`); + } status = "fail"; } finally { await gc.shutdown(); diff --git a/packages/taler-wallet-cli/src/harness/helpers.ts b/packages/taler-wallet-cli/src/harness/helpers.ts index 3840dcf94..db66efbb6 100644 --- a/packages/taler-wallet-cli/src/harness/helpers.ts +++ b/packages/taler-wallet-cli/src/harness/helpers.ts @@ -65,6 +65,13 @@ export interface SimpleTestEnvironment { wallet: WalletCli; } +export interface EnvOptions { + /** + * If provided, enable age restrictions with the specified age mask string. + */ + ageMaskSpec?: string; +} + /** * Run a test case with a simple TESTKUDOS Taler environment, consisting * of one exchange, one bank and one merchant. @@ -72,6 +79,7 @@ export interface SimpleTestEnvironment { export async function createSimpleTestkudosEnvironment( t: GlobalTestState, coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")), + opts: EnvOptions = {}, ): Promise { const db = await setupDb(t); @@ -108,7 +116,17 @@ export async function createSimpleTestkudosEnvironment( await bank.pingUntilAvailable(); - exchange.addCoinConfigList(coinConfig); + const ageMaskSpec = opts.ageMaskSpec; + + if (ageMaskSpec) { + exchange.enableAgeRestrictions(ageMaskSpec); + // Enable age restriction for all coins. + exchange.addCoinConfigList( + coinConfig.map((x) => ({ ...x, ageRestricted: true })), + ); + } else { + exchange.addCoinConfigList(coinConfig); + } await exchange.start(); await exchange.pingUntilAvailable(); @@ -259,6 +277,7 @@ export async function startWithdrawViaBank( bank: BankService; exchange: ExchangeServiceInterface; amount: AmountString; + restrictAge?: number; }, ): Promise { const { wallet, bank, exchange, amount } = p; @@ -270,6 +289,7 @@ export async function startWithdrawViaBank( await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, { talerWithdrawUri: wop.taler_withdraw_uri, + restrictAge: p.restrictAge, }); await wallet.runPending(); @@ -279,6 +299,7 @@ export async function startWithdrawViaBank( await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, { exchangeBaseUrl: exchange.baseUrl, talerWithdrawUri: wop.taler_withdraw_uri, + restrictAge: p.restrictAge, }); // Confirm it @@ -299,6 +320,7 @@ export async function withdrawViaBank( bank: BankService; exchange: ExchangeServiceInterface; amount: AmountString; + restrictAge?: number; }, ): Promise { const { wallet } = p; diff --git a/packages/taler-wallet-cli/src/integrationtests/test-age-restrictions.ts b/packages/taler-wallet-cli/src/integrationtests/test-age-restrictions.ts new file mode 100644 index 000000000..9f523ae5d --- /dev/null +++ b/packages/taler-wallet-cli/src/integrationtests/test-age-restrictions.ts @@ -0,0 +1,64 @@ +/* + This file is part of GNU Taler + (C) 2022 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 { defaultCoinConfig } from "../harness/denomStructures.js"; +import { GlobalTestState } from "../harness/harness.js"; +import { + createSimpleTestkudosEnvironment, + withdrawViaBank, + makeTestPayment, +} from "../harness/helpers.js"; + +/** + * Run test for basic, bank-integrated withdrawal and payment. + */ +export async function runAgeRestrictionsTest(t: GlobalTestState) { + // Set up test environment + + const { wallet, bank, exchange, merchant } = + await createSimpleTestkudosEnvironment( + t, + defaultCoinConfig.map((x) => x("TESTKUDOS")), + { + ageMaskSpec: "8:10:12:14:16:18:21", + }, + ); + + // Withdraw digital cash into the wallet. + + await withdrawViaBank(t, { + wallet, + bank, + exchange, + amount: "TESTKUDOS:20", + restrictAge: 13, + }); + + const order = { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + minimum_age: 9, + }; + + await makeTestPayment(t, { wallet, merchant, order }); + await wallet.runUntilDone(); +} + +runAgeRestrictionsTest.suites = ["wallet"]; diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts index d8dc569d2..dcbf84497 100644 --- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts +++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts @@ -25,6 +25,7 @@ import { shouldLingerInTest, TestRunResult, } from "../harness/harness.js"; +import { runAgeRestrictionsTest } from "./test-age-restrictions.js"; import { runBankApiTest } from "./test-bank-api"; import { runClaimLoopTest } from "./test-claim-loop"; import { runClauseSchnorrTest } from "./test-clause-schnorr.js"; @@ -103,6 +104,7 @@ interface TestMainFunction { } const allTests: TestMainFunction[] = [ + runAgeRestrictionsTest, runBankApiTest, runClaimLoopTest, runClauseSchnorrTest, diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts index fa754e354..052d50ca7 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -69,6 +69,10 @@ import { kdf, ecdheGetPublic, getRandomBytes, + AgeCommitmentProof, + AgeRestriction, + hashCoinPub, + HashCodeString, } from "@gnu-taler/taler-util"; import bigint from "big-integer"; import { DenominationRecord, TipCoinSource, WireFee } from "../db.js"; @@ -82,7 +86,7 @@ import { SignTrackTransactionRequest, } from "./cryptoTypes.js"; -//const logger = new Logger("cryptoImplementation.ts"); +const logger = new Logger("cryptoImplementation.ts"); /** * Interface for (asynchronous) cryptographic operations that @@ -547,12 +551,34 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { const denomPub = req.denomPub; if (denomPub.cipher === DenomKeyType.Rsa) { const reservePub = decodeCrock(req.reservePub); - const denomPubRsa = decodeCrock(denomPub.rsa_public_key); const derivedPlanchet = await tci.setupWithdrawalPlanchet(tci, { coinNumber: req.coinIndex, secretSeed: req.secretSeed, }); - const coinPubHash = hash(decodeCrock(derivedPlanchet.coinPub)); + + let maybeAcp: AgeCommitmentProof | undefined = undefined; + let maybeAgeCommitmentHash: string | undefined = undefined; + if (req.restrictAge) { + if (denomPub.age_mask === 0) { + throw Error( + "requested age restriction for a denomination that does not support age restriction", + ); + } + logger.info("creating age-restricted planchet"); + maybeAcp = await AgeRestriction.restrictionCommit( + denomPub.age_mask, + req.restrictAge, + ); + maybeAgeCommitmentHash = AgeRestriction.hashCommitment( + maybeAcp.commitment, + ); + } + + const coinPubHash = hashCoinPub( + derivedPlanchet.coinPub, + maybeAgeCommitmentHash, + ); + const blindResp = await tci.rsaBlind(tci, { bks: derivedPlanchet.bks, hm: encodeCrock(coinPubHash), @@ -589,6 +615,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { reservePub: encodeCrock(reservePub), withdrawSig: sigResult.sig, coinEvHash: encodeCrock(evHash), + ageCommitmentProof: maybeAcp, }; return planchet; } else { @@ -880,7 +907,23 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { ): Promise { // FIXME: put extensions here if used const hExt = new Uint8Array(64); - const hAgeCommitment = new Uint8Array(32); + let hAgeCommitment: Uint8Array; + let maybeAgeCommitmentHash: string | undefined = undefined; + let minimumAgeSig: string | undefined = undefined; + if (depositInfo.ageCommitmentProof) { + const ach = AgeRestriction.hashCommitment( + depositInfo.ageCommitmentProof.commitment, + ); + maybeAgeCommitmentHash = ach; + hAgeCommitment = decodeCrock(ach); + minimumAgeSig = AgeRestriction.commitmentAttest( + depositInfo.ageCommitmentProof, + depositInfo.requiredMinimumAge!, + ); + } else { + // All zeros. + hAgeCommitment = new Uint8Array(32); + } let d: Uint8Array; if (depositInfo.denomKeyType === DenomKeyType.Rsa) { d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT) @@ -914,6 +957,8 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { cipher: DenomKeyType.Rsa, rsa_signature: depositInfo.denomSig.rsa_signature, }, + age_commitment: depositInfo.ageCommitmentProof?.commitment.publicKeys, + minimum_age_sig: minimumAgeSig, }; return s; } else { @@ -999,10 +1044,19 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { coinNumber: coinIndex, transferSecret: transferSecretRes.h, }); + let newAc: AgeCommitmentProof | undefined = undefined; + let newAch: HashCodeString | undefined = undefined; + if (req.meltCoinAgeCommitmentProof) { + newAc = await AgeRestriction.commitmentDerive( + req.meltCoinAgeCommitmentProof, + transferSecretRes.h, + ); + newAch = AgeRestriction.hashCommitment(newAc.commitment); + } coinPriv = decodeCrock(fresh.coinPriv); coinPub = decodeCrock(fresh.coinPub); blindingFactor = decodeCrock(fresh.bks); - const coinPubHash = hash(coinPub); + const coinPubHash = hashCoinPub(fresh.coinPub, newAch); if (denomSel.denomPub.cipher !== DenomKeyType.Rsa) { throw Error("unsupported cipher, can't create refresh session"); } @@ -1035,8 +1089,16 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { const sessionHash = sessionHc.finish(); let confirmData: Uint8Array; - // FIXME: fill in age commitment - const hAgeCommitment = new Uint8Array(32); + let hAgeCommitment: Uint8Array; + if (req.meltCoinAgeCommitmentProof) { + hAgeCommitment = decodeCrock( + AgeRestriction.hashCommitment( + req.meltCoinAgeCommitmentProof.commitment, + ), + ); + } else { + hAgeCommitment = new Uint8Array(32); + } confirmData = buildSigPS(TalerSignaturePurpose.WALLET_COIN_MELT) .put(sessionHash) .put(decodeCrock(meltCoinDenomPubHash)) diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts index deff15071..fe5dbcec6 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -28,6 +28,7 @@ * Imports. */ import { + AgeCommitmentProof, AmountJson, CoinEnvelope, DenominationPubKey, @@ -55,6 +56,7 @@ export interface DeriveRefreshSessionRequest { meltCoinPub: string; meltCoinPriv: string; meltCoinDenomPubHash: string; + meltCoinAgeCommitmentProof?: AgeCommitmentProof; newCoinDenoms: RefreshNewDenomInfo[]; feeRefresh: AmountJson; } diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoDispatcher.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoDispatcher.ts index f6c8ae61e..2ef0d7c69 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoDispatcher.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoDispatcher.ts @@ -321,9 +321,9 @@ export class CryptoDispatcher { return new Promise((resolve, reject) => { let timedOut = false; const timeout = timer.after(5000, () => { - logger.warn("crypto RPC call timed out"); + logger.warn(`crypto RPC call ('${operation}') timed out`); timedOut = true; - reject(new Error("crypto RPC call timed out")); + reject(new Error(`crypto RPC call ('${operation}') timed out`)); }); p.then((x) => { if (timedOut) { diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index e3da35975..0a1b40d2a 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -40,6 +40,7 @@ import { CoinEnvelope, TalerProtocolTimestamp, TalerProtocolDuration, + AgeCommitmentProof, } from "@gnu-taler/taler-util"; import { RetryInfo } from "./util/retries.js"; import { PayCoinSelection } from "./util/coinSelection.js"; @@ -188,6 +189,15 @@ export interface ReserveRecord { */ bankInfo?: ReserveBankInfo; + /** + * Restrict withdrawals from this reserve to this age. + */ + restrictAge?: number; + + /** + * Pre-allocated ID of the withdrawal group for the first withdrawal + * on this reserve. + */ initialWithdrawalGroupId: string; /** @@ -600,6 +610,8 @@ export interface PlanchetRecord { coinEv: CoinEnvelope; coinEvHash: string; + + ageCommitmentProof?: AgeCommitmentProof; } /** @@ -724,6 +736,8 @@ export interface CoinRecord { * Used to prevent allocation of the same coin for two different payments. */ allocation?: CoinAllocation; + + ageCommitmentProof?: AgeCommitmentProof; } export interface CoinAllocation { @@ -1148,6 +1162,7 @@ export interface WalletContractData { wireMethod: string; wireInfoHash: string; maxDepositFee: AmountJson; + minimumAge?: number; } export enum AbortStatus { diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index 26bca8c14..39edd6307 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -33,6 +33,7 @@ import { ExchangeSignKeyJson, ExchangeWireJson, hashDenomPub, + j2s, LibtoolVersion, Logger, NotificationType, @@ -445,6 +446,7 @@ async function downloadExchangeKeysInfo( ); logger.info("received /keys response"); + logger.info(`${j2s(exchangeKeysJsonUnchecked)}`); if (exchangeKeysJsonUnchecked.denoms.length === 0) { throw TalerError.fromDetail( diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index fa36c724f..a1773547a 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -26,6 +26,7 @@ */ import { AbsoluteTime, + AgeRestriction, AmountJson, Amounts, codecForContractTerms, @@ -197,6 +198,14 @@ export interface CoinSelectionRequest { maxWireFee: AmountJson; maxDepositFee: AmountJson; + + /** + * Minimum age requirement for the coin selection. + * + * When present, only select coins with either no age restriction + * or coins with an age commitment that matches the minimum age. + */ + minimumAge?: number; } /** @@ -651,6 +660,7 @@ export function extractContractData( merchant: parsedContractTerms.merchant, products: parsedContractTerms.products, summaryI18n: parsedContractTerms.summary_i18n, + minimumAge: parsedContractTerms.minimum_age, }; } @@ -825,6 +835,8 @@ async function processDownloadProposalImpl( proposalResp.sig, ); + logger.trace(`extracted contract data: ${j2s(contractData)}`); + await ws.db .mktx((x) => ({ proposals: x.proposals, purchases: x.purchases })) .runReadWrite(async (tx) => { @@ -1379,6 +1391,11 @@ export async function generateDepositPermissions( const { coin, denom } = coinWithDenom[i]; let wireInfoHash: string; wireInfoHash = contractData.wireInfoHash; + logger.trace( + `signing deposit permission for coin with acp=${j2s( + coin.ageCommitmentProof, + )}`, + ); const dp = await ws.cryptoApi.signDepositPermission({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, @@ -1393,6 +1410,8 @@ export async function generateDepositPermissions( spendAmount: payCoinSel.coinContributions[i], timestamp: contractData.timestamp, wireInfoHash, + ageCommitmentProof: coin.ageCommitmentProof, + requiredMinimumAge: contractData.minimumAge, }); depositPermissions.push(dp); } diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 10584fb94..215676118 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -15,6 +15,8 @@ */ import { + AgeCommitment, + AgeRestriction, CoinPublicKeyString, DenomKeyType, encodeCrock, @@ -22,7 +24,9 @@ import { ExchangeProtocolVersion, ExchangeRefreshRevealRequest, getRandomBytes, + HashCodeString, HttpStatusCode, + j2s, TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; import { @@ -83,6 +87,7 @@ import { GetReadWriteAccess } from "../util/query.js"; import { guardOperationException } from "./common.js"; import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js"; import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js"; +import { TalerError } from "../errors.js"; const logger = new Logger("refresh.ts"); @@ -380,6 +385,7 @@ async function refreshMelt( meltCoinPriv: oldCoin.coinPriv, meltCoinPub: oldCoin.coinPub, feeRefresh: oldDenom.feeRefresh, + meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, newCoinDenoms, sessionSecretSeed: refreshSession.sessionSecretSeed, }); @@ -388,6 +394,14 @@ async function refreshMelt( `coins/${oldCoin.coinPub}/melt`, oldCoin.exchangeBaseUrl, ); + + let maybeAch: HashCodeString | undefined; + if (oldCoin.ageCommitmentProof) { + maybeAch = AgeRestriction.hashCommitment( + oldCoin.ageCommitmentProof.commitment, + ); + } + const meltReqBody: ExchangeMeltRequest = { coin_pub: oldCoin.coinPub, confirm_sig: derived.confirmSig, @@ -395,6 +409,7 @@ async function refreshMelt( denom_sig: oldCoin.denomSig, rc: derived.hash, value_with_fee: Amounts.stringify(derived.meltValueWithFee), + age_commitment_hash: maybeAch, }; const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => { @@ -475,6 +490,7 @@ export async function assembleRefreshRevealRequest(args: { denomPubHash: string; count: number; }[]; + oldAgeCommitment?: AgeCommitment; }): Promise { const { derived, @@ -517,6 +533,7 @@ export async function assembleRefreshRevealRequest(args: { transfer_privs: privs, transfer_pub: derived.transferPubs[norevealIndex], link_sigs: linkSigs, + old_age_commitment: args.oldAgeCommitment?.publicKeys, }; return req; } @@ -622,6 +639,7 @@ async function refreshReveal( meltCoinPub: oldCoin.coinPub, feeRefresh: oldDenom.feeRefresh, newCoinDenoms, + meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, sessionSecretSeed: refreshSession.sessionSecretSeed, }); @@ -637,6 +655,7 @@ async function refreshReveal( norevealIndex: norevealIndex, oldCoinPriv: oldCoin.coinPriv, oldCoinPub: oldCoin.coinPub, + oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment, }); const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => { @@ -822,6 +841,11 @@ async function processRefreshGroupImpl( logger.info( "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.", ); + } else if (x instanceof TalerError) { + logger.warn("process refresh session got exception (TalerError)"); + logger.warn(`exc ${x}`); + logger.warn(`exc stack ${x.stack}`); + logger.warn(`error detail: ${j2s(x.errorDetail)}`); } else { logger.warn("process refresh session got exception"); logger.warn(`exc ${x}`); diff --git a/packages/taler-wallet-core/src/operations/reserves.ts b/packages/taler-wallet-core/src/operations/reserves.ts index ff09d1a50..8e606bd60 100644 --- a/packages/taler-wallet-core/src/operations/reserves.ts +++ b/packages/taler-wallet-core/src/operations/reserves.ts @@ -200,6 +200,7 @@ export async function createReserve( lastError: undefined, currency: req.amount.currency, operationStatus: OperationStatus.Pending, + restrictAge: req.restrictAge, }; const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange); @@ -541,12 +542,9 @@ async function updateReserve( const reserveUrl = new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl); reserveUrl.searchParams.set("timeout_ms", "200"); - const resp = await ws.http.get( - reserveUrl.href, - { - timeout: getReserveRequestTimeout(reserve), - }, - ); + const resp = await ws.http.get(reserveUrl.href, { + timeout: getReserveRequestTimeout(reserve), + }); const result = await readSuccessResponseJsonOrErrorCode( resp, @@ -632,17 +630,12 @@ async function updateReserve( amountReservePlus, amountReserveMinus, ).amount; - const denomSel = selectWithdrawalDenominations( - remainingAmount, - denoms, - ); + const denomSel = selectWithdrawalDenominations(remainingAmount, denoms); logger.trace( `Remaining unclaimed amount in reseve is ${Amounts.stringify( remainingAmount, - )} and can be withdrawn with ${ - denomSel.selectedDenoms.length - } coins`, + )} and can be withdrawn with ${denomSel.selectedDenoms.length} coins`, ); if (denomSel.selectedDenoms.length === 0) { @@ -759,6 +752,7 @@ export async function createTalerWithdrawReserve( selectedExchange: string, options: { forcedDenomSel?: ForcedDenomSel; + restrictAge?: number; } = {}, ): Promise { await updateExchangeFromUrl(ws, selectedExchange); @@ -774,6 +768,7 @@ export async function createTalerWithdrawReserve( exchange: selectedExchange, senderWire: withdrawInfo.senderWire, exchangePaytoUri: exchangePaytoUri, + restrictAge: options.restrictAge, }); // We do this here, as the reserve should be registered before we return, // so that we can redirect the user to the bank's status page. diff --git a/packages/taler-wallet-core/src/operations/withdraw.test.ts b/packages/taler-wallet-core/src/operations/withdraw.test.ts index e5894a3e7..9f9146719 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.test.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.test.ts @@ -32,6 +32,7 @@ test("withdrawal selection bug repro", (t) => { cipher: DenomKeyType.Rsa, rsa_public_key: "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002", + age_mask: 0, }, denomPubHash: "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8", @@ -86,6 +87,7 @@ test("withdrawal selection bug repro", (t) => { cipher: DenomKeyType.Rsa, rsa_public_key: "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002", + age_mask: 0, }, denomPubHash: @@ -141,6 +143,7 @@ test("withdrawal selection bug repro", (t) => { cipher: DenomKeyType.Rsa, rsa_public_key: "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002", + age_mask: 0, }, denomPubHash: "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R", @@ -195,6 +198,7 @@ test("withdrawal selection bug repro", (t) => { cipher: DenomKeyType.Rsa, rsa_public_key: "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002", + age_mask: 0, }, denomPubHash: @@ -250,6 +254,7 @@ test("withdrawal selection bug repro", (t) => { cipher: DenomKeyType.Rsa, rsa_public_key: "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002", + age_mask: 0, }, denomPubHash: "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR", @@ -304,6 +309,7 @@ test("withdrawal selection bug repro", (t) => { cipher: DenomKeyType.Rsa, rsa_public_key: "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002", + age_mask: 0, }, denomPubHash: "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG", diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index d4ca58401..94f8e20b9 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -266,8 +266,6 @@ export function selectForcedWithdrawalDenominations( denoms: DenominationRecord[], forcedDenomSel: ForcedDenomSel, ): DenomSelectionState { - let remaining = Amounts.copy(amountAvailable); - const selectedDenoms: { count: number; denomPubHash: string; @@ -454,6 +452,7 @@ async function processPlanchetGenerate( value: denom.value, coinIndex: coinIdx, secretSeed: withdrawalGroup.secretSeed, + restrictAge: reserve.restrictAge, }); const newPlanchet: PlanchetRecord = { blindingKey: r.blindingKey, @@ -467,6 +466,7 @@ async function processPlanchetGenerate( withdrawalDone: false, withdrawSig: r.withdrawSig, withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + ageCommitmentProof: r.ageCommitmentProof, lastError: undefined, }; await ws.db @@ -701,6 +701,7 @@ async function processPlanchetVerifyAndStoreCoin( withdrawalGroupId: withdrawalGroup.withdrawalGroupId, }, suspended: false, + ageCommitmentProof: planchet.ageCommitmentProof, }; const planchetCoinPub = planchet.coinPub; @@ -1101,11 +1102,6 @@ export async function getExchangeWithdrawalInfo( } } - const withdrawFee = Amounts.sub( - selectedDenoms.totalWithdrawCost, - selectedDenoms.totalCoinValue, - ).amount; - const ret: ExchangeWithdrawDetails = { earliestDepositExpiration, exchangeInfo: exchange, @@ -1127,6 +1123,10 @@ export async function getExchangeWithdrawalInfo( return ret; } +export interface GetWithdrawalDetailsForUriOpts { + restrictAge?: number; +} + /** * Get more information about a taler://withdraw URI. * @@ -1137,6 +1137,7 @@ export async function getExchangeWithdrawalInfo( export async function getWithdrawalDetailsForUri( ws: InternalWalletState, talerWithdrawUri: string, + opts: GetWithdrawalDetailsForUriOpts = {}, ): Promise { logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); const info = await getBankWithdrawalInfo(ws.http, talerWithdrawUri); diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts index 1675a9a35..dc64a57dc 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts @@ -36,6 +36,7 @@ function fakeAci(current: string, feeDeposit: string): AvailableCoinInfo { denomPub: { cipher: DenomKeyType.Rsa, rsa_public_key: "foobar", + age_mask: 0, }, feeDeposit: a(feeDeposit), exchangeBaseUrl: "https://example.com/", diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index e17bbb805..96722aefb 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -843,6 +843,7 @@ async function dispatchRequestInternal( req.exchangeBaseUrl, { forcedDenomSel: req.forcedDenomSel, + restrictAge: req.restrictAge, }, ); } @@ -1207,7 +1208,7 @@ class InternalWalletStateImpl implements InternalWalletState { ) { this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory); this.cryptoApi = this.cryptoDispatcher.cryptoApi; - this.timerGroup = new TimerGroup(timer) + this.timerGroup = new TimerGroup(timer); } async getDenomInfo( -- cgit v1.2.3