taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 40273e71e65ec916c4a809191f4b56a130c462ce
parent 6f73cef93009747aaee4f29ecdf0327d847493bd
Author: Florian Dold <dold@taler.net>
Date:   Wed, 22 Jul 2026 12:19:02 +0200

wallet: add a crypto operation for exchange signing keys

Verifies the master signature over TALER_ExchangeSigningKeyValidityPS.  The
tests pin the set of signed fields, their order and the signature purpose.

Diffstat:
Apackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 32++++++++++++++++++++++++++++++++
2 files changed, 212 insertions(+), 0 deletions(-)

diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts @@ -0,0 +1,180 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + buildSigPS, + createEddsaKeyPair, + decodeCrock, + eddsaSign, + encodeCrock, + EddsaPublicKeyString, + EddsaSignatureString, + ExchangeSignKeyJson, + TalerProtocolTimestamp, + TalerSignaturePurpose, + timestampRoundedToBuffer, +} from "@gnu-taler/taler-util"; +import assert from "node:assert"; +import { test } from "node:test"; +import { nativeCryptoR } from "./cryptoImplementation.js"; + +const master = createEddsaKeyPair(); +const masterPub = encodeCrock(master.eddsaPub); + +const signKey = createEddsaKeyPair(); + +function signedSignKey( + over: { + start: TalerProtocolTimestamp; + expire: TalerProtocolTimestamp; + end: TalerProtocolTimestamp; + key: string; + }, + claimed: Partial<ExchangeSignKeyJson> = {}, +): ExchangeSignKeyJson { + const p = buildSigPS(TalerSignaturePurpose.MASTER_SIGNING_KEY_VALIDITY) + .put(timestampRoundedToBuffer(over.start)) + .put(timestampRoundedToBuffer(over.expire)) + .put(timestampRoundedToBuffer(over.end)) + .put(decodeCrock(over.key)) + .build(); + return { + key: over.key as EddsaPublicKeyString, + stamp_start: over.start, + stamp_expire: over.expire, + stamp_end: over.end, + master_sig: encodeCrock( + eddsaSign(p, master.eddsaPriv), + ) as EddsaSignatureString, + ...claimed, + }; +} + +const t = (s: number) => TalerProtocolTimestamp.fromSeconds(s); +const validOver = { + start: t(1000), + expire: t(2000), + end: t(3000), + key: encodeCrock(signKey.eddsaPub), +}; + +test("a correctly signed exchange signing key is accepted", async () => { + const res = await nativeCryptoR.isValidSignKey(nativeCryptoR, { + masterPub, + sk: signedSignKey(validOver), + }); + assert.strictEqual(res.valid, true); +}); + +test("a signing key signed by a different master key is rejected", async () => { + const other = createEddsaKeyPair(); + const res = await nativeCryptoR.isValidSignKey(nativeCryptoR, { + masterPub: encodeCrock(other.eddsaPub), + sk: signedSignKey(validOver), + }); + assert.strictEqual(res.valid, false); +}); + +test("a signature over a preimage missing one field is rejected", async () => { + // Sign preimages that each leave one field out. If the implementation left + // the same field out, one of these would verify. Together with the test + // above this pins the set of fields and their order. + const parts = [ + ["stamp_start", timestampRoundedToBuffer(validOver.start)], + ["stamp_expire", timestampRoundedToBuffer(validOver.expire)], + ["stamp_end", timestampRoundedToBuffer(validOver.end)], + ["key", decodeCrock(validOver.key)], + ] as const; + + for (let skip = 0; skip < parts.length; skip++) { + const b = buildSigPS(TalerSignaturePurpose.MASTER_SIGNING_KEY_VALIDITY); + parts.forEach(([, buf], i) => { + if (i !== skip) b.put(buf); + }); + const sk: ExchangeSignKeyJson = { + key: validOver.key as EddsaPublicKeyString, + stamp_start: validOver.start, + stamp_expire: validOver.expire, + stamp_end: validOver.end, + master_sig: encodeCrock( + eddsaSign(b.build(), master.eddsaPriv), + ) as EddsaSignatureString, + }; + const res = await nativeCryptoR.isValidSignKey(nativeCryptoR, { + masterPub, + sk, + }); + assert.strictEqual( + res.valid, + false, + `signature omitting ${parts[skip][0]} was accepted`, + ); + } +}); + +test("a signature over the fields in a different order is rejected", async () => { + const p = buildSigPS(TalerSignaturePurpose.MASTER_SIGNING_KEY_VALIDITY) + .put(timestampRoundedToBuffer(validOver.expire)) + .put(timestampRoundedToBuffer(validOver.start)) + .put(timestampRoundedToBuffer(validOver.end)) + .put(decodeCrock(validOver.key)) + .build(); + const res = await nativeCryptoR.isValidSignKey(nativeCryptoR, { + masterPub, + sk: { + key: validOver.key as EddsaPublicKeyString, + stamp_start: validOver.start, + stamp_expire: validOver.expire, + stamp_end: validOver.end, + master_sig: encodeCrock( + eddsaSign(p, master.eddsaPriv), + ) as EddsaSignatureString, + }, + }); + assert.strictEqual(res.valid, false); +}); + +test("a signature made with a different purpose is rejected", async () => { + const p = buildSigPS(TalerSignaturePurpose.MASTER_GLOBAL_FEES) + .put(timestampRoundedToBuffer(validOver.start)) + .put(timestampRoundedToBuffer(validOver.expire)) + .put(timestampRoundedToBuffer(validOver.end)) + .put(decodeCrock(validOver.key)) + .build(); + const res = await nativeCryptoR.isValidSignKey(nativeCryptoR, { + masterPub, + sk: { + key: validOver.key as EddsaPublicKeyString, + stamp_start: validOver.start, + stamp_expire: validOver.expire, + stamp_end: validOver.end, + master_sig: encodeCrock( + eddsaSign(p, master.eddsaPriv), + ) as EddsaSignatureString, + }, + }); + assert.strictEqual(res.valid, false); +}); + +test("a garbled signature is rejected", async () => { + const sk = signedSignKey(validOver); + const sigBytes = decodeCrock(sk.master_sig); + sigBytes[0] ^= 0xff; + const res = await nativeCryptoR.isValidSignKey(nativeCryptoR, { + masterPub, + sk: { ...sk, master_sig: encodeCrock(sigBytes) as EddsaSignatureString }, + }); + assert.strictEqual(res.valid, false); +}); diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -57,6 +57,7 @@ import { encryptContractForDeposit, encryptContractForMerge, ExchangeProtocolVersion, + ExchangeSignKeyJson, getRandomBytes, GlobalFees, hash, @@ -192,6 +193,8 @@ export interface TalerCryptoInterface { req: GlobalFeesValidationRequest, ): Promise<ValidationResult>; + isValidSignKey(req: SignKeyValidationRequest): Promise<ValidationResult>; + isValidDenom(req: DenominationValidationRequest): Promise<ValidationResult>; isValidWireAccount( @@ -389,6 +392,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<ValidationResult> { throw new Error("Function not implemented."); }, + isValidSignKey: function ( + req: SignKeyValidationRequest, + ): Promise<ValidationResult> { + throw new Error("Function not implemented."); + }, isValidContractTermsSignature: function ( req: ContractTermsValidationRequest, ): Promise<ValidationResult> { @@ -709,6 +717,11 @@ export interface GlobalFeesValidationRequest { masterPub: string; } +export interface SignKeyValidationRequest { + sk: ExchangeSignKeyJson; + masterPub: string; +} + export interface DenominationValidationRequest { stampStart: TalerProtocolTimestamp; stampExpireWithdraw: TalerProtocolTimestamp; @@ -1257,6 +1270,25 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { }, /** + * Check the master signature over an online signing key of the exchange. + */ + async isValidSignKey( + tci: TalerCryptoInterfaceR, + req: SignKeyValidationRequest, + ): Promise<ValidationResult> { + const { sk, masterPub } = req; + const p = buildSigPS(TalerSignaturePurpose.MASTER_SIGNING_KEY_VALIDITY) + .put(timestampRoundedToBuffer(sk.stamp_start)) + .put(timestampRoundedToBuffer(sk.stamp_expire)) + .put(timestampRoundedToBuffer(sk.stamp_end)) + .put(decodeCrock(sk.key)) + .build(); + const sig = decodeCrock(sk.master_sig); + const pub = decodeCrock(masterPub); + return { valid: eddsaVerify(p, sig, pub) }; + }, + + /** * Check if the signature of a denomination is valid. */ async isValidDenom(