wallet: support both protocol versions

This commit is contained in:
Florian Dold 2021-11-27 20:56:58 +01:00
parent 403de8170e
commit 5c4c25516d
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
24 changed files with 623 additions and 231 deletions

View File

@ -417,3 +417,26 @@ export function codecOptional<V>(innerCodec: Codec<V>): Codec<V | undefined> {
},
};
}
export type CodecType<T> = T extends Codec<infer X> ? X : any;
export function codecForEither<T extends Array<Codec<unknown>>>(
...alts: [...T]
): Codec<CodecType<T[number]>> {
return {
decode(x: any, c?: Context): any {
for (const alt of alts) {
try {
return alt.decode(x, c);
} catch (e) {
continue;
}
}
throw new DecodingError(
`No alternative matched at at ${renderContext(c)}`,
);
},
};
}
const x = codecForEither(codecForString(), codecForNumber());

View File

@ -27,14 +27,15 @@ export interface VersionMatchResult {
* Is the first version compatible with the second?
*/
compatible: boolean;
/**
* Is the first version older (-1), newser (+1) or
* Is the first version older (-1), newer (+1) or
* identical (0)?
*/
currentCmp: number;
}
interface Version {
export interface Version {
current: number;
revision: number;
age: number;
@ -64,7 +65,7 @@ export namespace LibtoolVersion {
return { compatible, currentCmp };
}
function parseVersion(v: string): Version | undefined {
export function parseVersion(v: string): Version | undefined {
const [currentStr, revisionStr, ageStr, ...rest] = v.split(":");
if (rest.length !== 0) {
return undefined;

View File

@ -55,7 +55,7 @@ export function setGlobalLogLevelFromString(logLevelStr: string) {
break;
default:
if (isNode) {
process.stderr.write(`Invalid log level, defaulting to WARNING`);
process.stderr.write(`Invalid log level, defaulting to WARNING\n`);
} else {
console.warn(`Invalid log level, defaulting to WARNING`);
}
@ -143,6 +143,7 @@ export class Logger {
case LogLevel.Info:
case LogLevel.Warn:
case LogLevel.Error:
return true;
case LogLevel.None:
return false;
}

View File

@ -349,10 +349,12 @@ export function hash(d: Uint8Array): Uint8Array {
return nacl.hash(d);
}
/**
* Hash a denomination public key according to the
* algorithm of exchange protocol v10.
*/
export function hashDenomPub(pub: DenominationPubKey): Uint8Array {
if (pub.cipher !== DenomKeyType.Rsa) {
throw Error("unsupported cipher");
}
if (pub.cipher === DenomKeyType.Rsa) {
const pubBuf = decodeCrock(pub.rsa_public_key);
const hashInputBuf = new ArrayBuffer(pubBuf.length + 4 + 4);
const uint8ArrayBuf = new Uint8Array(hashInputBuf);
@ -361,6 +363,11 @@ export function hashDenomPub(pub: DenominationPubKey): Uint8Array {
dv.setUint32(4, pub.cipher);
uint8ArrayBuf.set(pubBuf, 8);
return nacl.hash(uint8ArrayBuf);
} else if (pub.cipher === DenomKeyType.LegacyRsa) {
return hash(decodeCrock(pub.rsa_public_key));
} else {
throw Error(`unsupported cipher (${pub.cipher}), unable to hash`);
}
}
export function eddsaSign(msg: Uint8Array, eddsaPriv: Uint8Array): Uint8Array {

View File

@ -38,6 +38,7 @@ import {
codecForConstNumber,
buildCodecForUnion,
codecForConstString,
codecForEither,
} from "./codec.js";
import {
Timestamp,
@ -50,7 +51,7 @@ import { codecForAmountString } from "./amounts.js";
/**
* Denomination as found in the /keys response from the exchange.
*/
export class Denomination {
export class ExchangeDenomination {
/**
* Value of one coin of the denomination.
*/
@ -58,8 +59,11 @@ export class Denomination {
/**
* Public signing key of the denomination.
*
* The "string" alternative is for the old exchange protocol (v9) that
* only supports RSA keys.
*/
denom_pub: DenominationPubKey;
denom_pub: DenominationPubKey | string;
/**
* Fee for withdrawing.
@ -128,7 +132,7 @@ export class AuditorDenomSig {
/**
* Auditor information as given by the exchange in /keys.
*/
export class Auditor {
export class ExchangeAuditor {
/**
* Auditor's public key.
*/
@ -157,8 +161,10 @@ export interface RecoupRequest {
/**
* Signature over the coin public key by the denomination.
*
* The string variant is for the legacy exchange protocol.
*/
denom_sig: UnblindedSignature;
denom_sig: UnblindedSignature | string;
/**
* Coin public key of the coin we want to refund.
@ -198,11 +204,20 @@ export interface RecoupConfirmation {
old_coin_pub?: string;
}
export interface UnblindedSignature {
export type UnblindedSignature =
| RsaUnblindedSignature
| LegacyRsaUnblindedSignature;
export interface RsaUnblindedSignature {
cipher: DenomKeyType.Rsa;
rsa_signature: string;
}
export interface LegacyRsaUnblindedSignature {
cipher: DenomKeyType.LegacyRsa;
rsa_signature: string;
}
/**
* Deposit permission for a single coin.
*/
@ -211,18 +226,25 @@ export interface CoinDepositPermission {
* Signature by the coin.
*/
coin_sig: string;
/**
* Public key of the coin being spend.
*/
coin_pub: string;
/**
* Signature made by the denomination public key.
*
* The string variant is for legacy protocol support.
*/
ub_sig: UnblindedSignature;
ub_sig: UnblindedSignature | string;
/**
* The denomination public key associated with this coin.
*/
h_denom: string;
/**
* The amount that is subtracted from this coin with this payment.
*/
@ -358,6 +380,11 @@ export interface ContractTerms {
*/
h_wire: string;
/**
* Legacy wire hash, used for deposit operations with an older exchange.
*/
h_wire_legacy?: string;
/**
* Hash of the merchant's wire details.
*/
@ -662,7 +689,7 @@ export class ExchangeKeysJson {
/**
* List of offered denominations.
*/
denoms: Denomination[];
denoms: ExchangeDenomination[];
/**
* The exchange's master public key.
@ -672,7 +699,7 @@ export class ExchangeKeysJson {
/**
* The list of auditors (partially) auditing the exchange.
*/
auditors: Auditor[];
auditors: ExchangeAuditor[];
/**
* Timestamp when this response was issued.
@ -802,6 +829,7 @@ export class TipPickupGetResponse {
export enum DenomKeyType {
Rsa = 1,
ClauseSchnorr = 2,
LegacyRsa = 3,
}
export interface RsaBlindedDenominationSignature {
@ -809,18 +837,25 @@ export interface RsaBlindedDenominationSignature {
blinded_rsa_signature: string;
}
export interface LegacyRsaBlindedDenominationSignature {
cipher: DenomKeyType.LegacyRsa;
blinded_rsa_signature: string;
}
export interface CSBlindedDenominationSignature {
cipher: DenomKeyType.ClauseSchnorr;
}
export type BlindedDenominationSignature =
| RsaBlindedDenominationSignature
| CSBlindedDenominationSignature;
| CSBlindedDenominationSignature
| LegacyRsaBlindedDenominationSignature;
export const codecForBlindedDenominationSignature = () =>
buildCodecForUnion<BlindedDenominationSignature>()
.discriminateOn("cipher")
.alternative(1, codecForRsaBlindedDenominationSignature())
.alternative(3, codecForLegacyRsaBlindedDenominationSignature())
.build("BlindedDenominationSignature");
export const codecForRsaBlindedDenominationSignature = () =>
@ -829,8 +864,17 @@ export const codecForRsaBlindedDenominationSignature = () =>
.property("blinded_rsa_signature", codecForString())
.build("RsaBlindedDenominationSignature");
export const codecForLegacyRsaBlindedDenominationSignature = () =>
buildCodecForObject<LegacyRsaBlindedDenominationSignature>()
.property("cipher", codecForConstNumber(1))
.property("blinded_rsa_signature", codecForString())
.build("LegacyRsaBlindedDenominationSignature");
export class WithdrawResponse {
ev_sig: BlindedDenominationSignature;
/**
* The string variant is for legacy protocol support.
*/
ev_sig: BlindedDenominationSignature | string;
}
/**
@ -925,7 +969,10 @@ export interface ExchangeMeltResponse {
}
export interface ExchangeRevealItem {
ev_sig: BlindedDenominationSignature;
/**
* The string variant is for the legacy v9 protocol.
*/
ev_sig: BlindedDenominationSignature | string;
}
export interface ExchangeRevealResponse {
@ -1044,7 +1091,15 @@ export interface BankWithdrawalOperationPostResponse {
transfer_done: boolean;
}
export type DenominationPubKey = RsaDenominationPubKey | CsDenominationPubKey;
export type DenominationPubKey =
| RsaDenominationPubKey
| CsDenominationPubKey
| LegacyRsaDenominationPubKey;
export interface LegacyRsaDenominationPubKey {
cipher: DenomKeyType.LegacyRsa;
rsa_public_key: string;
}
export interface RsaDenominationPubKey {
cipher: DenomKeyType.Rsa;
@ -1061,6 +1116,7 @@ export const codecForDenominationPubKey = () =>
buildCodecForUnion<DenominationPubKey>()
.discriminateOn("cipher")
.alternative(1, codecForRsaDenominationPubKey())
.alternative(3, codecForLegacyRsaDenominationPubKey())
.build("DenominationPubKey");
export const codecForRsaDenominationPubKey = () =>
@ -1069,6 +1125,12 @@ export const codecForRsaDenominationPubKey = () =>
.property("rsa_public_key", codecForString())
.build("DenominationPubKey");
export const codecForLegacyRsaDenominationPubKey = () =>
buildCodecForObject<LegacyRsaDenominationPubKey>()
.property("cipher", codecForConstNumber(3))
.property("rsa_public_key", codecForString())
.build("LegacyRsaDenominationPubKey");
export const codecForBankWithdrawalOperationPostResponse = (): Codec<BankWithdrawalOperationPostResponse> =>
buildCodecForObject<BankWithdrawalOperationPostResponse>()
.property("transfer_done", codecForBoolean())
@ -1080,10 +1142,13 @@ export type EddsaSignatureString = string;
export type EddsaPublicKeyString = string;
export type CoinPublicKeyString = string;
export const codecForDenomination = (): Codec<Denomination> =>
buildCodecForObject<Denomination>()
export const codecForDenomination = (): Codec<ExchangeDenomination> =>
buildCodecForObject<ExchangeDenomination>()
.property("value", codecForString())
.property("denom_pub", codecForDenominationPubKey())
.property(
"denom_pub",
codecForEither(codecForDenominationPubKey(), codecForString()),
)
.property("fee_withdraw", codecForString())
.property("fee_deposit", codecForString())
.property("fee_refresh", codecForString())
@ -1101,8 +1166,8 @@ export const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> =>
.property("auditor_sig", codecForString())
.build("AuditorDenomSig");
export const codecForAuditor = (): Codec<Auditor> =>
buildCodecForObject<Auditor>()
export const codecForAuditor = (): Codec<ExchangeAuditor> =>
buildCodecForObject<ExchangeAuditor>()
.property("auditor_pub", codecForString())
.property("auditor_url", codecForString())
.property("denomination_keys", codecForList(codecForAuditorDenomSig()))
@ -1261,7 +1326,7 @@ export const codecForExchangeKeysJson = (): Codec<ExchangeKeysJson> =>
.property("signkeys", codecForList(codecForExchangeSigningKey()))
.property("version", codecForString())
.property("reserve_closing_delay", codecForDuration)
.build("KeysJson");
.build("ExchangeKeysJson");
export const codecForWireFeesJson = (): Codec<WireFeesJson> =>
buildCodecForObject<WireFeesJson>()
@ -1327,7 +1392,10 @@ export const codecForRecoupConfirmation = (): Codec<RecoupConfirmation> =>
export const codecForWithdrawResponse = (): Codec<WithdrawResponse> =>
buildCodecForObject<WithdrawResponse>()
.property("ev_sig", codecForBlindedDenominationSignature())
.property(
"ev_sig",
codecForEither(codecForBlindedDenominationSignature(), codecForString()),
)
.build("WithdrawResponse");
export const codecForMerchantPayResponse = (): Codec<MerchantPayResponse> =>
@ -1345,7 +1413,10 @@ export const codecForExchangeMeltResponse = (): Codec<ExchangeMeltResponse> =>
export const codecForExchangeRevealItem = (): Codec<ExchangeRevealItem> =>
buildCodecForObject<ExchangeRevealItem>()
.property("ev_sig", codecForBlindedDenominationSignature())
.property(
"ev_sig",
codecForEither(codecForBlindedDenominationSignature(), codecForString()),
)
.build("ExchangeRevealItem");
export const codecForExchangeRevealResponse = (): Codec<ExchangeRevealResponse> =>

View File

@ -49,6 +49,7 @@ import {
codecForContractTerms,
ContractTerms,
DenominationPubKey,
DenomKeyType,
UnblindedSignature,
} from "./talerTypes.js";
import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes.js";
@ -515,6 +516,7 @@ export interface DepositInfo {
merchantPub: string;
feeDeposit: AmountJson;
wireInfoHash: string;
denomKeyType: DenomKeyType;
denomPubHash: string;
denomSig: UnblindedSignature;
}

View File

@ -1173,6 +1173,17 @@ export class ExchangeService implements ExchangeServiceInterface {
}
async runAggregatorOnce() {
try {
await runCommand(
this.globalState,
`exchange-${this.name}-aggregator-once`,
"taler-exchange-aggregator",
[...this.timetravelArgArr, "-c", this.configFilename, "-t", "-y"],
);
} catch (e) {
console.log(
"running aggregator with KYC off didn't work, might be old version, running again",
);
await runCommand(
this.globalState,
`exchange-${this.name}-aggregator-once`,
@ -1180,6 +1191,7 @@ export class ExchangeService implements ExchangeServiceInterface {
[...this.timetravelArgArr, "-c", this.configFilename, "-t"],
);
}
}
async runTransferOnce() {
await runCommand(

View File

@ -1018,6 +1018,13 @@ const testCli = walletCli.subcommand("testingArgs", "testing", {
help: "Subcommands for testing.",
});
testCli.subcommand("logtest", "logtest").action(async (args) => {
logger.trace("This is a trace message.");
logger.info("This is an info message.");
logger.warn("This is an warning message.");
logger.error("This is an error message.");
});
testCli
.subcommand("listIntegrationtests", "list-integrationtests")
.action(async (args) => {

View File

@ -52,8 +52,7 @@ export interface TrustInfo {
}
export interface MerchantInfo {
supportsMerchantProtocolV1: boolean;
supportsMerchantProtocolV2: boolean;
protocolVersionCurrent: number;
}
/**

View File

@ -392,6 +392,7 @@ export class CryptoApi {
}
isValidWireAccount(
versionCurrent: number,
paytoUri: string,
sig: string,
masterPub: string,
@ -399,6 +400,7 @@ export class CryptoApi {
return this.doRpc<boolean>(
"isValidWireAccount",
4,
versionCurrent,
paytoUri,
sig,
masterPub,

View File

@ -154,9 +154,10 @@ export class CryptoImplementation {
* reserve.
*/
createPlanchet(req: PlanchetCreationRequest): PlanchetCreationResult {
if (req.denomPub.cipher !== 1) {
throw Error("unsupported cipher");
}
if (
req.denomPub.cipher === DenomKeyType.Rsa ||
req.denomPub.cipher === DenomKeyType.LegacyRsa
) {
const reservePub = decodeCrock(req.reservePub);
const reservePriv = decodeCrock(req.reservePriv);
const denomPubRsa = decodeCrock(req.denomPub.rsa_public_key);
@ -188,7 +189,7 @@ export class CryptoImplementation {
coinPub: encodeCrock(derivedPlanchet.coinPub),
coinValue: req.value,
denomPub: {
cipher: 1,
cipher: req.denomPub.cipher,
rsa_public_key: encodeCrock(denomPubRsa),
},
denomPubHash: encodeCrock(denomPubHash),
@ -197,13 +198,19 @@ export class CryptoImplementation {
coinEvHash: encodeCrock(evHash),
};
return planchet;
} else {
throw Error("unsupported cipher, unable to create planchet");
}
}
/**
* Create a planchet used for tipping, including the private keys.
*/
createTipPlanchet(req: DeriveTipRequest): DerivedTipPlanchet {
if (req.denomPub.cipher !== 1) {
if (
req.denomPub.cipher !== DenomKeyType.Rsa &&
req.denomPub.cipher !== DenomKeyType.LegacyRsa
) {
throw Error("unsupported cipher");
}
const fc = setupTipPlanchet(decodeCrock(req.secretSeed), req.planchetIndex);
@ -243,6 +250,19 @@ export class CryptoImplementation {
const coinPriv = decodeCrock(coin.coinPriv);
const coinSig = eddsaSign(p, coinPriv);
if (coin.denomPub.cipher === DenomKeyType.LegacyRsa) {
logger.info("creating legacy recoup request");
const paybackRequest: RecoupRequest = {
coin_blind_key_secret: coin.blindingKey,
coin_pub: coin.coinPub,
coin_sig: encodeCrock(coinSig),
denom_pub_hash: coin.denomPubHash,
denom_sig: coin.denomSig.rsa_signature,
refreshed: coin.coinSource.type === CoinSourceType.Refresh,
};
return paybackRequest;
} else {
logger.info("creating v10 recoup request");
const paybackRequest: RecoupRequest = {
coin_blind_key_secret: coin.blindingKey,
coin_pub: coin.coinPub,
@ -253,6 +273,7 @@ export class CryptoImplementation {
};
return paybackRequest;
}
}
/**
* Check if a payment signature is valid.
@ -326,15 +347,31 @@ export class CryptoImplementation {
}
isValidWireAccount(
versionCurrent: number,
paytoUri: string,
sig: string,
masterPub: string,
): boolean {
if (versionCurrent === 10) {
const paytoHash = hash(stringToBytes(paytoUri + "\0"));
const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS)
.put(paytoHash)
.build();
return eddsaVerify(p, decodeCrock(sig), decodeCrock(masterPub));
} else if (versionCurrent === 9) {
const h = kdf(
64,
stringToBytes("exchange-wire-signature"),
stringToBytes(paytoUri + "\0"),
new Uint8Array(0),
);
const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS)
.put(h)
.build();
return eddsaVerify(p, decodeCrock(sig), decodeCrock(masterPub));
} else {
throw Error(`unsupported version (${versionCurrent})`);
}
}
isValidContractTermsSignature(
@ -393,7 +430,10 @@ export class CryptoImplementation {
signDepositPermission(depositInfo: DepositInfo): CoinDepositPermission {
// FIXME: put extensions here if used
const hExt = new Uint8Array(64);
const d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT)
let d: Uint8Array;
if (depositInfo.denomKeyType === DenomKeyType.Rsa) {
logger.warn("signing v10 deposit permission");
d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT)
.put(decodeCrock(depositInfo.contractTermsHash))
.put(hExt)
.put(decodeCrock(depositInfo.wireInfoHash))
@ -404,8 +444,25 @@ export class CryptoImplementation {
.put(amountToBuffer(depositInfo.feeDeposit))
.put(decodeCrock(depositInfo.merchantPub))
.build();
} else if (depositInfo.denomKeyType === DenomKeyType.LegacyRsa) {
logger.warn("signing legacy deposit permission");
d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT)
.put(decodeCrock(depositInfo.contractTermsHash))
.put(decodeCrock(depositInfo.wireInfoHash))
.put(decodeCrock(depositInfo.denomPubHash))
.put(timestampRoundedToBuffer(depositInfo.timestamp))
.put(timestampRoundedToBuffer(depositInfo.refundDeadline))
.put(amountToBuffer(depositInfo.spendAmount))
.put(amountToBuffer(depositInfo.feeDeposit))
.put(decodeCrock(depositInfo.merchantPub))
.put(decodeCrock(depositInfo.coinPub))
.build();
} else {
throw Error("unsupported exchange protocol version");
}
const coinSig = eddsaSign(d, decodeCrock(depositInfo.coinPriv));
if (depositInfo.denomKeyType === DenomKeyType.Rsa) {
const s: CoinDepositPermission = {
coin_pub: depositInfo.coinPub,
coin_sig: encodeCrock(coinSig),
@ -418,6 +475,19 @@ export class CryptoImplementation {
},
};
return s;
} else if (depositInfo.denomKeyType === DenomKeyType.LegacyRsa) {
const s: CoinDepositPermission = {
coin_pub: depositInfo.coinPub,
coin_sig: encodeCrock(coinSig),
contribution: Amounts.stringify(depositInfo.spendAmount),
h_denom: depositInfo.denomPubHash,
exchange_url: depositInfo.exchangeBaseUrl,
ub_sig: depositInfo.denomSig.rsa_signature,
};
return s;
} else {
throw Error("unsupported merchant protocol version");
}
}
async deriveRefreshSession(
@ -466,12 +536,14 @@ export class CryptoImplementation {
for (const denomSel of newCoinDenoms) {
for (let i = 0; i < denomSel.count; i++) {
if (denomSel.denomPub.cipher !== 1) {
throw Error("unsupported cipher");
}
if (denomSel.denomPub.cipher === DenomKeyType.LegacyRsa) {
const r = decodeCrock(denomSel.denomPub.rsa_public_key);
sessionHc.update(r);
} else {
sessionHc.update(hashDenomPub(denomSel.denomPub));
}
}
}
sessionHc.update(decodeCrock(meltCoinPub));
sessionHc.update(amountToBuffer(valueWithFee));
@ -508,8 +580,11 @@ export class CryptoImplementation {
blindingFactor = fresh.bks;
}
const pubHash = hash(coinPub);
if (denomSel.denomPub.cipher !== 1) {
throw Error("unsupported cipher");
if (
denomSel.denomPub.cipher !== DenomKeyType.Rsa &&
denomSel.denomPub.cipher !== DenomKeyType.LegacyRsa
) {
throw Error("unsupported cipher, can't create refresh session");
}
const denomPub = decodeCrock(denomSel.denomPub.rsa_public_key);
const ev = rsaBlind(pubHash, blindingFactor, denomPub);

View File

@ -25,7 +25,7 @@ import {
import {
AmountJson,
AmountString,
Auditor,
ExchangeAuditor,
CoinDepositPermission,
ContractTerms,
DenominationPubKey,
@ -427,7 +427,7 @@ export interface ExchangeDetailsRecord {
/**
* Auditors (partially) auditing the exchange.
*/
auditors: Auditor[];
auditors: ExchangeAuditor[];
/**
* Last observed protocol version.
@ -1136,6 +1136,7 @@ export interface WalletContractData {
timestamp: Timestamp;
wireMethod: string;
wireInfoHash: string;
wireInfoLegacyHash?: string;
maxDepositFee: AmountJson;
}

View File

@ -27,6 +27,7 @@ import {
BackupRefundState,
RefreshReason,
BackupRefreshReason,
DenomKeyType,
} from "@gnu-taler/taler-util";
import {
WalletContractData,
@ -331,7 +332,10 @@ export async function importBackup(
}
for (const backupDenomination of backupExchangeDetails.denominations) {
if (backupDenomination.denom_pub.cipher !== 1) {
if (
backupDenomination.denom_pub.cipher !== DenomKeyType.Rsa &&
backupDenomination.denom_pub.cipher !== DenomKeyType.LegacyRsa
) {
throw Error("unsupported cipher");
}
const denomPubHash =

View File

@ -38,14 +38,15 @@ import {
codecForString,
codecOptional,
ConfirmPayResultType,
DenomKeyType,
durationFromSpec,
getTimestampNow,
hashDenomPub,
HttpStatusCode,
j2s,
LibtoolVersion,
Logger,
notEmpty,
NotificationType,
PreparePayResultType,
RecoveryLoadRequest,
RecoveryMergeStrategy,
@ -167,7 +168,10 @@ async function computeBackupCryptoData(
};
for (const backupExchangeDetails of backupContent.exchange_details) {
for (const backupDenom of backupExchangeDetails.denominations) {
if (backupDenom.denom_pub.cipher !== 1) {
if (
backupDenom.denom_pub.cipher !== DenomKeyType.Rsa &&
backupDenom.denom_pub.cipher !== DenomKeyType.LegacyRsa
) {
throw Error("unsupported cipher");
}
for (const backupCoin of backupDenom.coins) {
@ -184,9 +188,25 @@ async function computeBackupCryptoData(
coinPub,
};
}
if (
LibtoolVersion.compare(backupExchangeDetails.protocol_version, "9")
?.compatible
) {
cryptoData.rsaDenomPubToHash[
backupDenom.denom_pub.rsa_public_key
] = encodeCrock(
hash(decodeCrock(backupDenom.denom_pub.rsa_public_key)),
);
} else if (
LibtoolVersion.compare(backupExchangeDetails.protocol_version, "10")
?.compatible
) {
cryptoData.rsaDenomPubToHash[
backupDenom.denom_pub.rsa_public_key
] = encodeCrock(hashDenomPub(backupDenom.denom_pub));
} else {
throw Error("unsupported exchange protocol version");
}
}
for (const backupReserve of backupExchangeDetails.reserves) {
cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock(

View File

@ -26,6 +26,7 @@ import {
CreateDepositGroupRequest,
CreateDepositGroupResponse,
decodeCrock,
DenomKeyType,
durationFromSpec,
getTimestampNow,
Logger,
@ -59,6 +60,8 @@ import {
getCandidatePayCoins,
getEffectiveDepositAmount,
getTotalPaymentCost,
hashWire,
hashWireLegacy,
} from "./pay.js";
/**
@ -103,16 +106,6 @@ const codecForDepositSuccess = (): Codec<DepositSuccess> =>
.property("transaction_base_url", codecOptional(codecForString()))
.build("DepositSuccess");
function hashWire(paytoUri: string, salt: string): string {
const r = kdf(
64,
stringToBytes(paytoUri + "\0"),
decodeCrock(salt),
stringToBytes("merchant-wire-signature"),
);
return encodeCrock(r);
}
async function resetDepositGroupRetry(
ws: InternalWalletState,
depositGroupId: string,
@ -211,8 +204,34 @@ async function processDepositGroupImpl(
continue;
}
const perm = depositPermissions[i];
const url = new URL(`coins/${perm.coin_pub}/deposit`, perm.exchange_url);
const httpResp = await ws.http.postJson(url.href, {
let requestBody: any;
if (
typeof perm.ub_sig === "string" ||
perm.ub_sig.cipher === DenomKeyType.LegacyRsa
) {
// Legacy request
logger.info("creating legacy deposit request");
const wireHash = hashWireLegacy(
depositGroup.wire.payto_uri,
depositGroup.wire.salt,
);
requestBody = {
contribution: Amounts.stringify(perm.contribution),
wire: depositGroup.wire,
h_wire: wireHash,
h_contract_terms: depositGroup.contractTermsHash,
ub_sig: perm.ub_sig,
timestamp: depositGroup.contractTermsRaw.timestamp,
wire_transfer_deadline:
depositGroup.contractTermsRaw.wire_transfer_deadline,
refund_deadline: depositGroup.contractTermsRaw.refund_deadline,
coin_sig: perm.coin_sig,
denom_pub_hash: perm.h_denom,
merchant_pub: depositGroup.merchantPub,
};
} else {
logger.info("creating v10 deposit request");
requestBody = {
contribution: Amounts.stringify(perm.contribution),
merchant_payto_uri: depositGroup.wire.payto_uri,
wire_salt: depositGroup.wire.salt,
@ -225,7 +244,10 @@ async function processDepositGroupImpl(
coin_sig: perm.coin_sig,
denom_pub_hash: perm.h_denom,
merchant_pub: depositGroup.merchantPub,
});
};
}
const url = new URL(`coins/${perm.coin_pub}/deposit`, perm.exchange_url);
const httpResp = await ws.http.postJson(url.href, requestBody);
await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess());
await ws.db
.mktx((x) => ({ depositGroups: x.depositGroups }))
@ -358,6 +380,7 @@ export async function createDepositGroup(
const merchantPair = await ws.cryptoApi.createEddsaKeypair();
const wireSalt = encodeCrock(getRandomBytes(16));
const wireHash = hashWire(req.depositPaytoUri, wireSalt);
const wireHashLegacy = hashWireLegacy(req.depositPaytoUri, wireSalt);
const contractTerms: ContractTerms = {
auditors: [],
exchanges: exchangeInfos,
@ -371,7 +394,10 @@ export async function createDepositGroup(
nonce: noncePair.pub,
wire_transfer_deadline: timestampRound,
order_id: "",
// This is always the v2 wire hash, as we're the "merchant" and support v2.
h_wire: wireHash,
// Required for older exchanges.
h_wire_legacy: wireHashLegacy,
pay_deadline: timestampAddDuration(
timestampRound,
durationFromSpec({ hours: 1 }),

View File

@ -19,11 +19,11 @@
*/
import {
Amounts,
Auditor,
ExchangeAuditor,
canonicalizeBaseUrl,
codecForExchangeKeysJson,
codecForExchangeWireJson,
Denomination,
ExchangeDenomination,
Duration,
durationFromSpec,
ExchangeSignKeyJson,
@ -40,6 +40,9 @@ import {
Timestamp,
hashDenomPub,
LibtoolVersion,
codecForAny,
DenominationPubKey,
DenomKeyType,
} from "@gnu-taler/taler-util";
import { decodeCrock, encodeCrock, hash } from "@gnu-taler/taler-util";
import { CryptoApi } from "../crypto/workers/cryptoApi.js";
@ -77,11 +80,21 @@ function denominationRecordFromKeys(
exchangeBaseUrl: string,
exchangeMasterPub: string,
listIssueDate: Timestamp,
denomIn: Denomination,
denomIn: ExchangeDenomination,
): DenominationRecord {
const denomPubHash = encodeCrock(hashDenomPub(denomIn.denom_pub));
let denomPub: DenominationPubKey;
// We support exchange protocol v9 and v10.
if (typeof denomIn.denom_pub === "string") {
denomPub = {
cipher: DenomKeyType.LegacyRsa,
rsa_public_key: denomIn.denom_pub,
};
} else {
denomPub = denomIn.denom_pub;
}
const denomPubHash = encodeCrock(hashDenomPub(denomPub));
const d: DenominationRecord = {
denomPub: denomIn.denom_pub,
denomPub,
denomPubHash,
exchangeBaseUrl,
exchangeMasterPub,
@ -205,6 +218,7 @@ export async function acceptExchangeTermsOfService(
}
async function validateWireInfo(
versionCurrent: number,
wireInfo: ExchangeWireJson,
masterPublicKey: string,
cryptoApi: CryptoApi,
@ -212,6 +226,7 @@ async function validateWireInfo(
for (const a of wireInfo.accounts) {
logger.trace("validating exchange acct");
const isValid = await cryptoApi.isValidWireAccount(
versionCurrent,
a.payto_uri,
a.master_sig,
masterPublicKey,
@ -321,7 +336,7 @@ async function provideExchangeRecord(
interface ExchangeKeysDownloadResult {
masterPublicKey: string;
currency: string;
auditors: Auditor[];
auditors: ExchangeAuditor[];
currentDenominations: DenominationRecord[];
protocolVersion: string;
signingKeys: ExchangeSignKeyJson[];
@ -345,14 +360,14 @@ async function downloadKeysInfo(
const resp = await http.get(keysUrl.href, {
timeout,
});
const exchangeKeysJson = await readSuccessResponseJsonOrThrow(
const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangeKeysJson(),
);
logger.info("received /keys response");
if (exchangeKeysJson.denoms.length === 0) {
if (exchangeKeysJsonUnchecked.denoms.length === 0) {
const opErr = makeErrorDetails(
TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
"exchange doesn't offer any denominations",
@ -363,7 +378,7 @@ async function downloadKeysInfo(
throw new OperationFailedError(opErr);
}
const protocolVersion = exchangeKeysJson.version;
const protocolVersion = exchangeKeysJsonUnchecked.version;
const versionRes = LibtoolVersion.compare(
WALLET_EXCHANGE_PROTOCOL_VERSION,
@ -382,29 +397,29 @@ async function downloadKeysInfo(
}
const currency = Amounts.parseOrThrow(
exchangeKeysJson.denoms[0].value,
exchangeKeysJsonUnchecked.denoms[0].value,
).currency.toUpperCase();
return {
masterPublicKey: exchangeKeysJson.master_public_key,
masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
currency,
auditors: exchangeKeysJson.auditors,
currentDenominations: exchangeKeysJson.denoms.map((d) =>
auditors: exchangeKeysJsonUnchecked.auditors,
currentDenominations: exchangeKeysJsonUnchecked.denoms.map((d) =>
denominationRecordFromKeys(
baseUrl,
exchangeKeysJson.master_public_key,
exchangeKeysJson.list_issue_date,
exchangeKeysJsonUnchecked.master_public_key,
exchangeKeysJsonUnchecked.list_issue_date,
d,
),
),
protocolVersion: exchangeKeysJson.version,
signingKeys: exchangeKeysJson.signkeys,
reserveClosingDelay: exchangeKeysJson.reserve_closing_delay,
protocolVersion: exchangeKeysJsonUnchecked.version,
signingKeys: exchangeKeysJsonUnchecked.signkeys,
reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
expiry: getExpiryTimestamp(resp, {
minDuration: durationFromSpec({ hours: 1 }),
}),
recoup: exchangeKeysJson.recoup ?? [],
listIssueDate: exchangeKeysJson.list_issue_date,
recoup: exchangeKeysJsonUnchecked.recoup ?? [],
listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
};
}
@ -466,7 +481,14 @@ async function updateExchangeFromUrlImpl(
logger.info("validating exchange /wire info");
const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
if (!version) {
// Should have been validated earlier.
throw Error("unexpected invalid version");
}
const wireInfo = await validateWireInfo(
version.current,
wireInfoDownload,
keysInfo.masterPublicKey,
ws.cryptoApi,

View File

@ -52,15 +52,13 @@ export async function getMerchantInfo(
`merchant "${canonBaseUrl}" reports protocol ${configResp.version}"`,
);
const parsedVersion = LibtoolVersion.parseVersion(configResp.version);
if (!parsedVersion) {
throw Error("invalid merchant version");
}
const merchantInfo: MerchantInfo = {
supportsMerchantProtocolV1: !!LibtoolVersion.compare(
"1:0:0",
configResp.version,
)?.compatible,
supportsMerchantProtocolV2: !!LibtoolVersion.compare(
"2:0:0",
configResp.version,
)?.compatible,
protocolVersionCurrent: parsedVersion.current,
};
ws.merchantInfoCache[canonBaseUrl] = merchantInfo;

View File

@ -54,6 +54,10 @@ import {
URL,
getDurationRemaining,
HttpStatusCode,
DenomKeyType,
kdf,
stringToBytes,
decodeCrock,
} from "@gnu-taler/taler-util";
import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
import {
@ -108,6 +112,26 @@ import {
*/
const logger = new Logger("pay.ts");
export function hashWire(paytoUri: string, salt: string): string {
const r = kdf(
64,
stringToBytes(paytoUri + "\0"),
decodeCrock(salt),
stringToBytes("merchant-wire-signature"),
);
return encodeCrock(r);
}
export function hashWireLegacy(paytoUri: string, salt: string): string {
const r = kdf(
64,
stringToBytes(paytoUri + "\0"),
stringToBytes(salt + "\0"),
stringToBytes("merchant-wire-signature"),
);
return encodeCrock(r);
}
/**
* Compute the total cost of a payment to the customer.
*
@ -669,6 +693,7 @@ export function extractContractData(
timestamp: parsedContractTerms.timestamp,
wireMethod: parsedContractTerms.wire_method,
wireInfoHash: parsedContractTerms.h_wire,
wireInfoLegacyHash: parsedContractTerms.h_wire_legacy,
maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
merchant: parsedContractTerms.merchant,
products: parsedContractTerms.products,
@ -882,7 +907,6 @@ async function startDownloadProposal(
claimToken: string | undefined,
noncePriv: string | undefined,
): Promise<string> {
const oldProposal = await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadOnly(async (tx) => {
@ -896,15 +920,19 @@ async function startDownloadProposal(
* If we have already claimed this proposal with the same sessionId
* nonce and claim token, reuse it.
*/
if (oldProposal &&
if (
oldProposal &&
oldProposal.downloadSessionId === sessionId &&
(!noncePriv || oldProposal.noncePriv === noncePriv) &&
oldProposal.claimToken === claimToken) {
oldProposal.claimToken === claimToken
) {
await processDownloadProposal(ws, oldProposal.proposalId);
return oldProposal.proposalId;
}
const { priv, pub } = await (noncePriv ? ws.cryptoApi.eddsaGetPublic(noncePriv) : ws.cryptoApi.createEddsaKeypair());
const { priv, pub } = await (noncePriv
? ws.cryptoApi.eddsaGetPublic(noncePriv)
: ws.cryptoApi.createEddsaKeypair());
const proposalId = encodeCrock(getRandomBytes(32));
const proposalRecord: ProposalRecord = {
@ -1169,6 +1197,11 @@ async function submitPay(
logger.trace("paying with session ID", sessionId);
const merchantInfo = await ws.merchantOps.getMerchantInfo(
ws,
purchase.download.contractData.merchantBaseUrl,
);
if (!purchase.merchantPaySig) {
const payUrl = new URL(
`orders/${purchase.download.contractData.orderId}/pay`,
@ -1568,11 +1601,21 @@ export async function generateDepositPermissions(
for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
const { coin, denom } = coinWithDenom[i];
let wireInfoHash: string;
if (
coin.denomPub.cipher === DenomKeyType.LegacyRsa &&
contractData.wireInfoLegacyHash
) {
wireInfoHash = contractData.wireInfoLegacyHash;
} else {
wireInfoHash = contractData.wireInfoHash;
}
const dp = await ws.cryptoApi.signDepositPermission({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contractTermsHash: contractData.contractTermsHash,
denomPubHash: coin.denomPubHash,
denomKeyType: coin.denomPub.cipher,
denomSig: coin.denomSig,
exchangeBaseUrl: coin.exchangeBaseUrl,
feeDeposit: denom.feeDeposit,
@ -1580,7 +1623,7 @@ export async function generateDepositPermissions(
refundDeadline: contractData.refundDeadline,
spendAmount: payCoinSel.coinContributions[i],
timestamp: contractData.timestamp,
wireInfoHash: contractData.wireInfoHash,
wireInfoHash,
});
depositPermissions.push(dp);
}
@ -1613,6 +1656,11 @@ export async function confirmPay(
throw Error("proposal is in invalid state");
}
const merchantInfo = await ws.merchantOps.getMerchantInfo(
ws,
d.contractData.merchantBaseUrl,
);
const existingPurchase = await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => {

View File

@ -365,7 +365,18 @@ async function refreshMelt(
`coins/${oldCoin.coinPub}/melt`,
oldCoin.exchangeBaseUrl,
);
const meltReq = {
let meltReqBody: any;
if (oldCoin.denomPub.cipher === DenomKeyType.LegacyRsa) {
meltReqBody = {
coin_pub: oldCoin.coinPub,
confirm_sig: derived.confirmSig,
denom_pub_hash: oldCoin.denomPubHash,
denom_sig: oldCoin.denomSig.rsa_signature,
rc: derived.hash,
value_with_fee: Amounts.stringify(derived.meltValueWithFee),
};
} else {
meltReqBody = {
coin_pub: oldCoin.coinPub,
confirm_sig: derived.confirmSig,
denom_pub_hash: oldCoin.denomPubHash,
@ -373,10 +384,10 @@ async function refreshMelt(
rc: derived.hash,
value_with_fee: Amounts.stringify(derived.meltValueWithFee),
};
logger.trace(`melt request for coin:`, meltReq);
}
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
return await ws.http.postJson(reqUrl.href, meltReq, {
return await ws.http.postJson(reqUrl.href, meltReqBody, {
timeout: getRefreshRequestTimeout(refreshGroup),
});
});
@ -604,15 +615,26 @@ async function refreshReveal(
continue;
}
const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
if (denom.denomPub.cipher !== 1) {
if (
denom.denomPub.cipher !== DenomKeyType.Rsa &&
denom.denomPub.cipher !== DenomKeyType.LegacyRsa
) {
throw Error("cipher unsupported");
}
const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
if (evSig.cipher !== DenomKeyType.Rsa) {
let rsaSig: string;
if (typeof evSig === "string") {
rsaSig = evSig;
} else if (
evSig.cipher === DenomKeyType.Rsa ||
evSig.cipher === DenomKeyType.LegacyRsa
) {
rsaSig = evSig.blinded_rsa_signature;
} else {
throw Error("unsupported cipher");
}
const denomSigRsa = await ws.cryptoApi.rsaUnblind(
evSig.blinded_rsa_signature,
rsaSig,
pc.blindingKey,
denom.denomPub.rsa_public_key,
);

View File

@ -314,13 +314,13 @@ async function processTipImpl(
let blindedSigs: BlindedDenominationSignature[] = [];
if (merchantInfo.supportsMerchantProtocolV2) {
if (merchantInfo.protocolVersionCurrent === 2) {
const response = await readSuccessResponseJsonOrThrow(
merchantResp,
codecForMerchantTipResponseV2(),
);
blindedSigs = response.blind_sigs.map((x) => x.blind_sig);
} else if (merchantInfo.supportsMerchantProtocolV1) {
} else if (merchantInfo.protocolVersionCurrent === 1) {
const response = await readSuccessResponseJsonOrThrow(
merchantResp,
codecForMerchantTipResponseV1(),
@ -347,11 +347,17 @@ async function processTipImpl(
const planchet = planchets[i];
checkLogicInvariant(!!planchet);
if (denom.denomPub.cipher !== DenomKeyType.Rsa) {
if (
denom.denomPub.cipher !== DenomKeyType.Rsa &&
denom.denomPub.cipher !== DenomKeyType.LegacyRsa
) {
throw Error("unsupported cipher");
}
if (blindedSig.cipher !== DenomKeyType.Rsa) {
if (
blindedSig.cipher !== DenomKeyType.Rsa &&
blindedSig.cipher !== DenomKeyType.LegacyRsa
) {
throw Error("unsupported cipher");
}

View File

@ -42,6 +42,7 @@ import {
VersionMatchResult,
DenomKeyType,
LibtoolVersion,
UnblindedSignature,
} from "@gnu-taler/taler-util";
import {
CoinRecord,
@ -591,12 +592,28 @@ async function processPlanchetVerifyAndStoreCoin(
const { planchet, exchangeBaseUrl } = d;
const planchetDenomPub = planchet.denomPub;
if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
throw Error("cipher not supported");
if (
planchetDenomPub.cipher !== DenomKeyType.Rsa &&
planchetDenomPub.cipher !== DenomKeyType.LegacyRsa
) {
throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
}
const evSig = resp.ev_sig;
if (evSig.cipher !== DenomKeyType.Rsa) {
let evSig = resp.ev_sig;
if (typeof resp.ev_sig === "string") {
evSig = {
cipher: DenomKeyType.LegacyRsa,
blinded_rsa_signature: resp.ev_sig,
};
} else {
evSig = resp.ev_sig;
}
if (
!(
evSig.cipher === DenomKeyType.Rsa ||
evSig.cipher === DenomKeyType.LegacyRsa
)
) {
throw Error("unsupported cipher");
}
@ -633,6 +650,19 @@ async function processPlanchetVerifyAndStoreCoin(
return;
}
let denomSig: UnblindedSignature;
if (
planchet.denomPub.cipher === DenomKeyType.LegacyRsa ||
planchet.denomPub.cipher === DenomKeyType.Rsa
) {
denomSig = {
cipher: planchet.denomPub.cipher,
rsa_signature: denomSigRsa,
};
} else {
throw Error("unsupported cipher");
}
const coin: CoinRecord = {
blindingKey: planchet.blindingKey,
coinPriv: planchet.coinPriv,
@ -640,10 +670,7 @@ async function processPlanchetVerifyAndStoreCoin(
currentAmount: planchet.coinValue,
denomPub: planchet.denomPub,
denomPubHash: planchet.denomPubHash,
denomSig: {
cipher: DenomKeyType.Rsa,
rsa_signature: denomSigRsa,
},
denomSig,
coinEvHash: planchet.coinEvHash,
exchangeBaseUrl: exchangeBaseUrl,
status: CoinStatus.Fresh,

View File

@ -23,7 +23,12 @@
/**
* Imports.
*/
import { AmountJson, Amounts, DenominationPubKey } from "@gnu-taler/taler-util";
import {
AmountJson,
Amounts,
DenominationPubKey,
DenomKeyType,
} from "@gnu-taler/taler-util";
import { strcmp, Logger } from "@gnu-taler/taler-util";
const logger = new Logger("coinSelection.ts");
@ -215,10 +220,21 @@ function denomPubCmp(
} else if (p1.cipher > p2.cipher) {
return +1;
}
if (p1.cipher !== 1 || p2.cipher !== 1) {
throw Error("unsupported cipher");
if (
p1.cipher === DenomKeyType.LegacyRsa &&
p2.cipher === DenomKeyType.LegacyRsa
) {
return strcmp(p1.rsa_public_key, p2.rsa_public_key);
} else if (p1.cipher === DenomKeyType.Rsa && p2.cipher === DenomKeyType.Rsa) {
if ((p1.age_mask ?? 0) < (p2.age_mask ?? 0)) {
return -1;
} else if ((p1.age_mask ?? 0) > (p2.age_mask ?? 0)) {
return 1;
}
return strcmp(p1.rsa_public_key, p2.rsa_public_key);
} else {
throw Error("unsupported cipher");
}
}
/**

View File

@ -19,14 +19,14 @@
*
* Uses libtool's current:revision:age versioning.
*/
export const WALLET_EXCHANGE_PROTOCOL_VERSION = "10:0:0";
export const WALLET_EXCHANGE_PROTOCOL_VERSION = "10:0:1";
/**
* Protocol version spoken with the merchant.
*
* Uses libtool's current:revision:age versioning.
*/
export const WALLET_MERCHANT_PROTOCOL_VERSION = "1:0:0";
export const WALLET_MERCHANT_PROTOCOL_VERSION = "2:0:1";
/**
* Protocol version spoken with the merchant.
@ -42,4 +42,4 @@ export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "0:0:0";
*
* This is only a temporary measure.
*/
export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "3";
export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "4";

View File

@ -390,7 +390,7 @@ async function runTaskLoop(
} catch (e) {
if (e instanceof OperationFailedAndReportedError) {
logger.warn("operation processed resulted in reported error");
logger.warn(`reporred error was: ${j2s(e.operationError)}`);
logger.warn(`reported error was: ${j2s(e.operationError)}`);
} else {
logger.error("Uncaught exception", e);
ws.notify({
@ -985,6 +985,8 @@ export async function handleCoreApiRequest(
e instanceof OperationFailedError ||
e instanceof OperationFailedAndReportedError
) {
logger.error("Caught operation failed error");
logger.trace((e as any).stack);
return {
type: "error",
operation,