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