gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] branch master updated: wallet-core: implement age re


From: gnunet
Subject: [taler-wallet-core] branch master updated: wallet-core: implement age restriction support
Date: Wed, 27 Apr 2022 00:50:20 +0200

This is an automated email from the git hooks/post-receive script.

dold pushed a commit to branch master
in repository wallet-core.

The following commit(s) were added to refs/heads/master by this push:
     new a165afa6 wallet-core: implement age restriction support
a165afa6 is described below

commit a165afa6824980c409d7c2e22e24171e536800e0
Author: Florian Dold <florian@dold.me>
AuthorDate: Tue Apr 19 17:12:43 2022 +0200

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

diff --git a/packages/taler-util/src/nacl-fast.ts 
b/packages/taler-util/src/nacl-fast.ts
index 82bdc7ce..c45674be 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 70ad8a61..5e8f37d8 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 282d22d8..228dc326 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<string, 
"Edx25519PublicKey", 32>;
 export type Edx25519PrivateKey = FlavorP<string, "Edx25519PrivateKey", 64>;
 export type Edx25519Signature = FlavorP<string, "Edx25519Signature", 64>;
 
+/**
+ * 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<OpaqueData> {
     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<Edx25519PrivateKey> {
     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<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 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<Edx25519PublicKey> {
-    throw Error("not implemented")
+  export async function restrictionCommit(
+    ageMask: number,
+    age: number,
+  ): Promise<AgeCommitmentProof> {
+    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<AgeCommitmentProof> {
+    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 b1bf6ab3..abac1cd1 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<RsaDenominationPubKey>()
     .property("cipher", codecForConstString(DenomKeyType.Rsa))
     .property("rsa_public_key", codecForString())
+    .property("age_mask", codecForNumber())
     .build("DenominationPubKey");
 
 export const codecForCsDenominationPubKey = () =>
   buildCodecForObject<CsDenominationPubKey>()
     .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<ContractTerms> =>
     .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 818ba37f..e094bc38 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<CreateReserveRequest> =>
@@ -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<ApplyRefundRequest> =>
 
 export interface GetWithdrawalDetailsForUriRequest {
   talerWithdrawUri: string;
+  restrictAge?: number;
 }
 export const codecForGetWithdrawalDetailsForUri =
   (): Codec<GetWithdrawalDetailsForUriRequest> =>
     buildCodecForObject<GetWithdrawalDetailsForUriRequest>()
       .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 2ca77703..b12857c7 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 30503e48..a2339e5f 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 3840dcf9..db66efbb 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<SimpleTestEnvironment> {
   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<void> {
   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<void> {
   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 00000000..9f523ae5
--- /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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * 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 d8dc569d..dcbf8449 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 fa754e35..052d50ca 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<CoinDepositPermission> {
     // 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 deff1507..fe5dbcec 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 f6c8ae61..2ef0d7c6 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<T>((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 e3da3597..0a1b40d2 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 26bca8c1..39edd630 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 fa36c724..a1773547 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 10584fb9..21567611 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<ExchangeRefreshRevealRequest> {
   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 ff09d1a5..8e606bd6 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<AcceptWithdrawalResponse> {
   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 e5894a3e..9f914671 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 d4ca5840..94f8e20b 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<WithdrawUriInfoResponse> {
   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 1675a9a3..dc64a57d 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 e17bbb80..96722aef 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(

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]