diff --git a/package-lock.json b/package-lock.json index f1d6e7cc..67df9f20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.0-rc1", "license": "MIT", "dependencies": { - "@cashu/crypto": "^0.2.7", + "@cashu/crypto": "^0.3.4", "@noble/curves": "^1.3.0", "@noble/hashes": "^1.3.3", "@scure/bip32": "^1.3.3", @@ -620,15 +620,14 @@ "dev": true }, "node_modules/@cashu/crypto": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.2.7.tgz", - "integrity": "sha512-1aaDfUjiHNXoJqg8nW+341TLWV9W28DsVNXJUKcHL0yAmwLs5+56SSnb8LLDJzPamLVoYL0U0bda91klAzptig==", - "license": "MIT", - "dependencies": { - "@noble/curves": "^1.3.0", - "@noble/hashes": "^1.3.3", - "@scure/bip32": "^1.3.3", - "@scure/bip39": "^1.2.2", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.3.4.tgz", + "integrity": "sha512-mfv1Pj4iL1PXzUj9NKIJbmncCLMqYfnEDqh/OPxAX0nNBt6BOnVJJLjLWFlQeYxlnEfWABSNkrqPje1t5zcyhA==", + "dependencies": { + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", "buffer": "^6.0.3" } }, @@ -1167,22 +1166,25 @@ } }, "node_modules/@noble/curves": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.3.0.tgz", - "integrity": "sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.6.0.tgz", + "integrity": "sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ==", "dependencies": { - "@noble/hashes": "1.3.3" + "@noble/hashes": "1.5.0" + }, + "engines": { + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", - "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", "engines": { - "node": ">= 16" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -1224,33 +1226,33 @@ } }, "node_modules/@scure/base": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.5.tgz", - "integrity": "sha512-Brj9FiG2W1MRQSTB212YVPRrcbjkv48FoZi/u4l/zds/ieRrqsh7aUf6CLwkAq61oKXr/ZlTzlY66gLIj3TFTQ==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip32": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.3.tgz", - "integrity": "sha512-LJaN3HwRbfQK0X1xFSi0Q9amqOgzQnnDngIt+ZlsBC3Bm7/nE7K0kwshZHyaru79yIVRv/e1mQAjZyuZG6jOFQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.5.0.tgz", + "integrity": "sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw==", "dependencies": { - "@noble/curves": "~1.3.0", - "@noble/hashes": "~1.3.2", - "@scure/base": "~1.1.4" + "@noble/curves": "~1.6.0", + "@noble/hashes": "~1.5.0", + "@scure/base": "~1.1.7" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip39": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.2.tgz", - "integrity": "sha512-HYf9TUXG80beW+hGAt3TRM8wU6pQoYur9iNypTROm42dorCGmLnFe3eWjz3gOq6G62H2WRh0FCzAR1PI+29zIA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.4.0.tgz", + "integrity": "sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw==", "dependencies": { - "@noble/hashes": "~1.3.2", - "@scure/base": "~1.1.4" + "@noble/hashes": "~1.5.0", + "@scure/base": "~1.1.8" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -6832,14 +6834,14 @@ "dev": true }, "@cashu/crypto": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.2.7.tgz", - "integrity": "sha512-1aaDfUjiHNXoJqg8nW+341TLWV9W28DsVNXJUKcHL0yAmwLs5+56SSnb8LLDJzPamLVoYL0U0bda91klAzptig==", - "requires": { - "@noble/curves": "^1.3.0", - "@noble/hashes": "^1.3.3", - "@scure/bip32": "^1.3.3", - "@scure/bip39": "^1.2.2", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.3.4.tgz", + "integrity": "sha512-mfv1Pj4iL1PXzUj9NKIJbmncCLMqYfnEDqh/OPxAX0nNBt6BOnVJJLjLWFlQeYxlnEfWABSNkrqPje1t5zcyhA==", + "requires": { + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", "buffer": "^6.0.3" } }, @@ -7256,17 +7258,17 @@ } }, "@noble/curves": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.3.0.tgz", - "integrity": "sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.6.0.tgz", + "integrity": "sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ==", "requires": { - "@noble/hashes": "1.3.3" + "@noble/hashes": "1.5.0" } }, "@noble/hashes": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", - "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==" }, "@nodelib/fs.scandir": { "version": "2.1.5", @@ -7295,27 +7297,27 @@ } }, "@scure/base": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.5.tgz", - "integrity": "sha512-Brj9FiG2W1MRQSTB212YVPRrcbjkv48FoZi/u4l/zds/ieRrqsh7aUf6CLwkAq61oKXr/ZlTzlY66gLIj3TFTQ==" + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==" }, "@scure/bip32": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.3.tgz", - "integrity": "sha512-LJaN3HwRbfQK0X1xFSi0Q9amqOgzQnnDngIt+ZlsBC3Bm7/nE7K0kwshZHyaru79yIVRv/e1mQAjZyuZG6jOFQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.5.0.tgz", + "integrity": "sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw==", "requires": { - "@noble/curves": "~1.3.0", - "@noble/hashes": "~1.3.2", - "@scure/base": "~1.1.4" + "@noble/curves": "~1.6.0", + "@noble/hashes": "~1.5.0", + "@scure/base": "~1.1.7" } }, "@scure/bip39": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.2.tgz", - "integrity": "sha512-HYf9TUXG80beW+hGAt3TRM8wU6pQoYur9iNypTROm42dorCGmLnFe3eWjz3gOq6G62H2WRh0FCzAR1PI+29zIA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.4.0.tgz", + "integrity": "sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw==", "requires": { - "@noble/hashes": "~1.3.2", - "@scure/base": "~1.1.4" + "@noble/hashes": "~1.5.0", + "@scure/base": "~1.1.8" } }, "@sinclair/typebox": { diff --git a/package.json b/package.json index b40a5680..747cb8fb 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "typescript": "^5.0.4" }, "dependencies": { - "@cashu/crypto": "^0.2.7", + "@cashu/crypto": "^0.3.4", "@noble/curves": "^1.3.0", "@noble/hashes": "^1.3.3", "@scure/bip32": "^1.3.3", diff --git a/src/CashuWallet.ts b/src/CashuWallet.ts index f65512cf..45901cd1 100644 --- a/src/CashuWallet.ts +++ b/src/CashuWallet.ts @@ -1,4 +1,4 @@ -import { bytesToHex, randomBytes } from '@noble/hashes/utils'; +import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'; import { CashuMint } from './CashuMint.js'; import { BlindedMessage } from './model/BlindedMessage.js'; import { @@ -19,9 +19,19 @@ import { GetInfoResponse, OutputAmounts, ProofState, - BlindingData + BlindingData, + SerializedDLEQ } from './model/types/index.js'; -import { bytesToNumber, getDecodedToken, splitAmount, sumProofs, getKeepAmounts } from './utils.js'; +import { + bytesToNumber, + getDecodedToken, + splitAmount, + sumProofs, + getKeepAmounts, + numberToHexPadded64, + hasValidDleq, + stripDleq +} from './utils.js'; import { hashToCurve, pointFromHex } from '@cashu/crypto/modules/common'; import { blindMessage, @@ -30,8 +40,8 @@ import { } from '@cashu/crypto/modules/client'; import { deriveBlindingFactor, deriveSecret } from '@cashu/crypto/modules/client/NUT09'; import { createP2PKsecret, getSignedProofs } from '@cashu/crypto/modules/client/NUT11'; -import { type Proof as NUT11Proof } from '@cashu/crypto/modules/common/index'; - +import { type Proof as NUT11Proof, DLEQ } from '@cashu/crypto/modules/common/index'; +import { verifyDLEQProof_reblind } from '@cashu/crypto/modules/client/NUT12'; /** * The default number of proofs per denomination to keep in a wallet. */ @@ -237,6 +247,7 @@ class CashuWallet { * @param options.counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect * @param options.pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! * @param options.privkey? will create a signature on the @param token secrets if set + * @param options.requireDleq? will check each proof for DLEQ proofs. Reject the token if any one of them can't be verified. * @returns New token with newly created proofs, token entries that had errors */ async receive( @@ -248,12 +259,18 @@ class CashuWallet { counter?: number; pubkey?: string; privkey?: string; + requireDleq?: boolean; } ): Promise> { if (typeof token === 'string') { token = getDecodedToken(token); } const keys = await this.getKeys(options?.keysetId); + if (options?.requireDleq) { + if (token.proofs.some((p: Proof) => !hasValidDleq(p, keys))) { + throw new Error('Token contains proofs with invalid DLEQ'); + } + } const amount = sumProofs(token.proofs) - this.getFeesForProofs(token.proofs); const { payload, blindingData } = this.createSwapPayload( amount, @@ -286,6 +303,7 @@ class CashuWallet { * @param options.keysetId? override the keysetId derived from the current mintKeys with a custom one. This should be a keyset that was fetched from the `/keysets` endpoint * @param options.offline? optionally send proofs offline. * @param options.includeFees? optionally include fees in the response. + * @param options.includeDleq? optionally include DLEQ proof in the proofs to send. * @returns {SendResponse} */ async send( @@ -300,8 +318,12 @@ class CashuWallet { keysetId?: string; offline?: boolean; includeFees?: boolean; + includeDleq?: boolean; } ): Promise { + if (options?.includeDleq) { + proofs = proofs.filter((p: Proof) => p.dleq != undefined); + } if (sumProofs(proofs) < amount) { throw new Error('Not enough funds available to send'); } @@ -328,15 +350,24 @@ class CashuWallet { ); options?.proofsWeHave?.push(...keepProofsSelect); - const { keep, send } = await this.swap(amount, sendProofs, options); - const keepProofs = keepProofsSelect.concat(keep); - return { keep: keepProofs, send }; + let { keep, send } = await this.swap(amount, sendProofs, options); + keep = keepProofsSelect.concat(keep); + + if (!options?.includeDleq) { + send = stripDleq(send); + } + + return { keep, send }; } if (sumProofs(sendProofOffline) < amount + expectedFee) { throw new Error('Not enough funds available to send'); } + if (!options?.includeDleq) { + return { keep: keepProofsOffline, send: stripDleq(sendProofOffline) }; + } + return { keep: keepProofsOffline, send: sendProofOffline }; } @@ -383,6 +414,7 @@ class CashuWallet { if (sumProofs(selectedProofs) < amountToSend + selectedFeePPK && nextBigger) { selectedProofs = [nextBigger]; } + return { keep: proofs.filter((p: Proof) => !selectedProofs.includes(p)), send: selectedProofs @@ -564,7 +596,6 @@ class CashuWallet { * @param start set starting point for count (first cycle for each keyset should usually be 0) * @param count set number of blinded messages that should be generated * @param options.keysetId set a custom keysetId to restore from. keysetIds can be loaded with `CashuMint.getKeySets()` - * @returns proofs */ async restore( start: number, @@ -594,7 +625,6 @@ class CashuWallet { const validSecrets = secrets.filter((_: Uint8Array, i: number) => outputs.map((o: SerializedBlindedMessage) => o.B_).includes(blindedMessages[i].B_) ); - return { proofs: this.constructProofs(promises, validBlindingFactors, validSecrets, keys) }; @@ -738,6 +768,9 @@ class CashuWallet { options.privkey ).map((p: NUT11Proof) => serializeProof(p)); } + + proofsToSend = stripDleq(proofsToSend); + const meltPayload: MeltPayload = { quote: meltQuote.quote, inputs: proofsToSend, @@ -813,6 +846,8 @@ class CashuWallet { ).map((p: NUT11Proof) => serializeProof(p)); } + proofsToSend = stripDleq(proofsToSend); + // join keepBlindedMessages and sendBlindedMessages const blindingData: BlindingData = { blindedMessages: [ @@ -968,15 +1003,40 @@ class CashuWallet { secrets: Array, keyset: MintKeys ): Array { - return promises - .map((p: SerializedBlindedSignature, i: number) => { - const blindSignature = { id: p.id, amount: p.amount, C_: pointFromHex(p.C_) }; - const r = rs[i]; - const secret = secrets[i]; - const A = pointFromHex(keyset.keys[p.amount]); - return constructProofFromPromise(blindSignature, r, secret, A); - }) - .map((p: NUT11Proof) => serializeProof(p) as Proof); + return promises.map((p: SerializedBlindedSignature, i: number) => { + const dleq = + p.dleq == undefined + ? undefined + : ({ + s: hexToBytes(p.dleq.s), + e: hexToBytes(p.dleq.e), + r: rs[i] + } as DLEQ); + const blindSignature = { + id: p.id, + amount: p.amount, + C_: pointFromHex(p.C_), + dleq: dleq + }; + const r = rs[i]; + const secret = secrets[i]; + const A = pointFromHex(keyset.keys[p.amount]); + const proof = constructProofFromPromise(blindSignature, r, secret, A); + const serializedProof = { + ...serializeProof(proof), + ...(dleq && { + dleqValid: verifyDLEQProof_reblind(secret, dleq, proof.C, A) + }), + ...(dleq && { + dleq: { + s: bytesToHex(dleq.s), + e: bytesToHex(dleq.e), + r: numberToHexPadded64(dleq.r ?? BigInt(0)) + } as SerializedDLEQ + }) + } as Proof; + return serializedProof; + }); } } diff --git a/src/model/BlindedSignature.ts b/src/model/BlindedSignature.ts index e998f320..684fa50f 100644 --- a/src/model/BlindedSignature.ts +++ b/src/model/BlindedSignature.ts @@ -1,19 +1,35 @@ import { ProjPointType } from '@noble/curves/abstract/weierstrass'; import { SerializedBlindedSignature } from './types/index.js'; +import { DLEQ } from '@cashu/crypto/modules/common'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { numberToHexPadded64 } from '../utils.js'; class BlindedSignature { id: string; amount: number; C_: ProjPointType; + dleq?: DLEQ; - constructor(id: string, amount: number, C_: ProjPointType) { + constructor(id: string, amount: number, C_: ProjPointType, dleq?: DLEQ) { this.id = id; this.amount = amount; this.C_ = C_; + this.dleq = dleq; } getSerializedBlindedSignature(): SerializedBlindedSignature { - return { id: this.id, amount: this.amount, C_: this.C_.toHex(true) }; + return { + id: this.id, + amount: this.amount, + C_: this.C_.toHex(true), + ...(this.dleq && { + dleq: { + s: bytesToHex(this.dleq.s), + e: bytesToHex(this.dleq.e), + r: numberToHexPadded64(this.dleq.r ?? BigInt(0)) + } + }) + }; } } diff --git a/src/model/types/mint/responses.ts b/src/model/types/mint/responses.ts index 43a6ccd0..80d5edf5 100644 --- a/src/model/types/mint/responses.ts +++ b/src/model/types/mint/responses.ts @@ -178,6 +178,16 @@ export type PostRestoreResponse = { promises: Array; }; +/* + * Zero-Knowledge that BlindedSignature + * was generated using a specific public key + */ +export type SerializedDLEQ = { + s: string; + e: string; + r?: string; +}; + /** * Blinded signature as it is received from the mint */ @@ -194,6 +204,10 @@ export type SerializedBlindedSignature = { * Blinded signature */ C_: string; + /** + * DLEQ Proof + */ + dleq?: SerializedDLEQ; }; /** diff --git a/src/model/types/wallet/index.ts b/src/model/types/wallet/index.ts index 853df5d9..641eac64 100644 --- a/src/model/types/wallet/index.ts +++ b/src/model/types/wallet/index.ts @@ -1,3 +1,5 @@ +import { SerializedDLEQ } from '../mint'; + export * from './payloads'; export * from './responses'; export * from './tokens'; @@ -23,6 +25,14 @@ export type Proof = { * The unblinded signature for this secret, signed by the mints private key. */ C: string; + /** + * DLEQ proof + */ + dleq?: SerializedDLEQ; + /** + * Is the associated DLEQ proof valid? + */ + dleqValid?: boolean; }; /** diff --git a/src/model/types/wallet/tokens.ts b/src/model/types/wallet/tokens.ts index 8d32662b..011a8b4f 100644 --- a/src/model/types/wallet/tokens.ts +++ b/src/model/types/wallet/tokens.ts @@ -22,6 +22,21 @@ export type Token = { unit?: string; }; +export type V4DLEQTemplate = { + /** + * challenge + */ + e: Uint8Array; + /** + * response + */ + s: Uint8Array; + /** + * blinding factor + */ + r: Uint8Array; +}; + /** * Template for a Proof inside a V4 Token */ @@ -38,6 +53,10 @@ export type V4ProofTemplate = { * Signature */ c: Uint8Array; + /** + * DLEQ + */ + d?: V4DLEQTemplate; }; /** diff --git a/src/utils.ts b/src/utils.ts index 826e2705..32f5a89b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,9 +7,12 @@ import { import { DeprecatedToken, Keys, + MintKeys, Proof, + SerializedDLEQ, Token, TokenV4Template, + V4DLEQTemplate, V4InnerToken, V4ProofTemplate } from './model/types/index.js'; @@ -18,6 +21,8 @@ import { bytesToHex, hexToBytes } from '@noble/curves/abstract/utils'; import { sha256 } from '@noble/hashes/sha256'; import { decodeCBOR, encodeCBOR } from './cbor.js'; import { PaymentRequest } from './model/PaymentRequest.js'; +import { DLEQ, pointFromHex } from '@cashu/crypto/modules/common'; +import { verifyDLEQProof_reblind } from '@cashu/crypto/modules/client/NUT12'; /** * Splits the amount into denominations of the provided @param keyset @@ -150,6 +155,15 @@ export function hexToNumber(hex: string): bigint { return BigInt(`0x${hex}`); } +/** + * Converts a number to a hex string of 64 characters. + * @param number (bigint) to conver to hex + * @returns hex string start-padded to 64 characters + */ +export function numberToHexPadded64(number: bigint): string { + return number.toString(16).padStart(64, '0'); +} + function isValidHex(str: string) { return /^[a-f0-9]*$/i.test(str); } @@ -204,6 +218,12 @@ export function getEncodedToken(token: Token, opts?: { version: 3 | 4 }): string } export function getEncodedTokenV4(token: Token): string { + // Make sure each DLEQ has its blinding factor + token.proofs.forEach((p) => { + if (p.dleq && p.dleq.r == undefined) { + throw new Error('Missing blinding factor in included DLEQ proof'); + } + }); const nonHex = hasNonHexId(token.proofs); if (nonHex) { throw new Error('can not encode to v4 token if proofs contain non-hex keyset id'); @@ -225,7 +245,18 @@ export function getEncodedTokenV4(token: Token): string { (id: string): V4InnerToken => ({ i: hexToBytes(id), p: idMap[id].map( - (p: Proof): V4ProofTemplate => ({ a: p.amount, s: p.secret, c: hexToBytes(p.C) }) + (p: Proof): V4ProofTemplate => ({ + a: p.amount, + s: p.secret, + c: hexToBytes(p.C), + ...(p.dleq && { + d: { + e: hexToBytes(p.dleq.e), + s: hexToBytes(p.dleq.s), + r: hexToBytes(p.dleq.r ?? '00') + } as V4DLEQTemplate + }) + }) ) }) ) @@ -292,7 +323,14 @@ export function handleTokens(token: string): Token { secret: p.s, C: bytesToHex(p.c), amount: p.a, - id: bytesToHex(t.i) + id: bytesToHex(t.i), + ...(p.d && { + dleq: { + r: bytesToHex(p.d.r), + s: bytesToHex(p.d.s), + e: bytesToHex(p.d.e) + } as SerializedDLEQ + }) }); }) ); @@ -360,3 +398,51 @@ export function sumProofs(proofs: Array) { export function decodePaymentRequest(paymentRequest: string) { return PaymentRequest.fromEncodedRequest(paymentRequest); } + +/** + * Removes all traces of DLEQs from a list of proofs + * @param proofs The list of proofs that dleq should be stripped from + */ +export function stripDleq(proofs: Array): Array> { + return proofs.map((p) => { + const newP = { ...p }; + delete newP['dleq']; + delete newP['dleqValid']; + return newP; + }); +} + +/** + * Checks that the proof has a valid DLEQ proof according to + * keyset `keys` + * @param proof The proof subject to verification + * @param keyset The Mint's keyset to be used for verification + * @returns true if verification succeeded, false otherwise + * @throws Error if @param proof does not match any key in @param keyset + */ +export function hasValidDleq(proof: Proof, keyset: MintKeys): boolean { + if (proof.dleq == undefined) { + return false; + } + const dleq = { + e: hexToBytes(proof.dleq.e), + s: hexToBytes(proof.dleq.s), + r: hexToNumber(proof.dleq.r ?? '00') + } as DLEQ; + if (!hasCorrespondingKey(proof.amount, keyset.keys)) { + throw new Error(`undefined key for amount ${proof.amount}`); + } + const key = keyset.keys[proof.amount]; + if ( + !verifyDLEQProof_reblind( + new TextEncoder().encode(proof.secret), + dleq, + pointFromHex(proof.C), + pointFromHex(key) + ) + ) { + return false; + } + + return true; +} diff --git a/test/integration.test.ts b/test/integration.test.ts index d6528d82..e0b0d9a5 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -2,10 +2,17 @@ import { CashuMint } from '../src/CashuMint.js'; import { CashuWallet } from '../src/CashuWallet.js'; import dns from 'node:dns'; -import { deriveKeysetId, getEncodedToken, sumProofs } from '../src/utils.js'; +import { + deriveKeysetId, + getEncodedToken, + getEncodedTokenV4, + hexToNumber, + numberToHexPadded64, + sumProofs +} from '../src/utils.js'; import { secp256k1 } from '@noble/curves/secp256k1'; import { bytesToHex } from '@noble/curves/abstract/utils'; -import { CheckStateEnum, MeltQuoteState } from '../src/model/types/index.js'; +import { CheckStateEnum, MeltQuoteState, Token } from '../src/model/types/index.js'; dns.setDefaultResultOrder('ipv4first'); const externalInvoice = @@ -254,3 +261,118 @@ describe('mint api', () => { expect(response.quote.state == MeltQuoteState.PAID).toBe(true); }); }); +describe('dleq', () => { + test('mint and check dleq', async () => { + const mint = new CashuMint(mintUrl); + const NUT12 = (await mint.getInfo()).nuts['12']; + if (NUT12 == undefined || !NUT12.supported) { + throw new Error('Cannot run this test: mint does not support NUT12'); + } + const wallet = new CashuWallet(mint); + + const mintRequest = await wallet.createMintQuote(3000); + const { proofs } = await wallet.mintProofs(3000, mintRequest.quote); + + proofs.forEach((p) => { + expect(p).toHaveProperty('dleq'); + expect(p.dleq).toHaveProperty('s'); + expect(p.dleq).toHaveProperty('e'); + expect(p.dleq).toHaveProperty('r'); + expect(p).toHaveProperty('dleqValid', true); + }); + }); + test('send and receive token with dleq', async () => { + const mint = new CashuMint(mintUrl); + const wallet = new CashuWallet(mint); + const NUT12 = (await mint.getInfo()).nuts['12']; + if (NUT12 == undefined || !NUT12.supported) { + throw new Error('Cannot run this test: mint does not support NUT12'); + } + + const mintRequest = await wallet.createMintQuote(8); + const { proofs } = await wallet.mintProofs(8, mintRequest.quote); + + const { keep, send } = await wallet.send(4, proofs, { includeDleq: true }); + + send.forEach((p) => { + expect(p.dleq).toBeDefined(); + expect(p.dleq?.r).toBeDefined(); + }); + + const token = { + mint: mint.mintUrl, + proofs: send + } as Token; + const encodedToken = getEncodedTokenV4(token); + const newProofs = await wallet.receive(encodedToken, { requireDleq: true }); + console.log(getEncodedTokenV4(token)); + expect(newProofs).toBeDefined(); + }); + test('send strip dleq', async () => { + const mint = new CashuMint(mintUrl); + const wallet = new CashuWallet(mint); + const NUT12 = (await mint.getInfo()).nuts['12']; + if (NUT12 == undefined || !NUT12.supported) { + throw new Error('Cannot run this test: mint does not support NUT12'); + } + + const mintRequest = await wallet.createMintQuote(8); + const { proofs } = await wallet.mintProofs(8, mintRequest.quote); + + const { keep, send } = await wallet.send(4, proofs, { includeDleq: false }); + send.forEach((p) => { + expect(p.dleq).toBeUndefined(); + }); + keep.forEach((p) => { + expect(p.dleq).toBeDefined(); + expect(p.dleq?.r).toBeDefined(); + }); + }); + test('send not enough proofs when dleq is required', async () => { + const mint = new CashuMint(mintUrl); + const wallet = new CashuWallet(mint); + const NUT12 = (await mint.getInfo()).nuts['12']; + if (NUT12 == undefined || !NUT12.supported) { + throw new Error('Cannot run this test: mint does not support NUT12'); + } + + const mintRequest = await wallet.createMintQuote(8); + let { proofs } = await wallet.mintProofs(8, mintRequest.quote); + + // strip dleq + proofs = proofs.map((p) => { + return { ...p, dleq: undefined }; + }); + + const exc = await wallet.send(4, proofs, { includeDleq: true }).catch((e) => e); + expect(exc).toEqual(new Error('Not enough funds available to send')); + }); + test('receive with invalid dleq', async () => { + const mint = new CashuMint(mintUrl); + const keys = await mint.getKeys(); + const wallet = new CashuWallet(mint); + const NUT12 = (await mint.getInfo()).nuts['12']; + if (NUT12 == undefined || !NUT12.supported) { + throw new Error('Cannot run this test: mint does not support NUT12'); + } + + const mintRequest = await wallet.createMintQuote(8); + let { proofs } = await wallet.mintProofs(8, mintRequest.quote); + + // alter dleq signature + proofs.forEach((p) => { + if (p.dleq != undefined) { + const s = hexToNumber(p.dleq.s) + BigInt(1); + p.dleq.s = numberToHexPadded64(s); + } + }); + + const token = { + mint: mint.mintUrl, + proofs: proofs + } as Token; + + const exc = await wallet.receive(token, { requireDleq: true }).catch((e) => e); + expect(exc).toEqual(new Error('Token contains proofs with invalid DLEQ')); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts index 77838393..cafa3de0 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,6 +1,16 @@ -import { Token, Keys, Proof } from '../src/model/types/index.js'; +import { + blindMessage, + constructProofFromPromise, + serializeProof +} from '@cashu/crypto/modules/client'; +import { Keys, Proof } from '../src/model/types/index.js'; import * as utils from '../src/utils.js'; import { PUBKEYS } from './consts.js'; +import { createDLEQProof } from '@cashu/crypto/modules/mint/NUT12'; +import { hasValidDleq, hexToNumber, numberToHexPadded64 } from '../src/utils.js'; +import { bytesToHex, hexToBytes } from '@noble/curves/abstract/utils'; +import { createBlindSignature, getPubKeyFromPrivKey } from '@cashu/crypto/modules/mint'; +import { pointFromBytes } from '@cashu/crypto/modules/common'; const keys: Keys = {}; for (let i = 1; i <= 2048; i *= 2) { @@ -353,3 +363,54 @@ describe('test output selection', () => { expect(amountsToKeep).toEqual([1, 1, 2, 2, 8, 8]); }); }); +describe('test zero-knowledge utilities', () => { + // create private public key pair + const privkey = hexToBytes('1'.padStart(64, '0')); + const pubkey = pointFromBytes(getPubKeyFromPrivKey(privkey)); + + // make up a secret + const fakeSecret = new TextEncoder().encode('fakeSecret'); + // make up blinding factor + const r = hexToNumber('123456'.padStart(64, '0')); + // blind secret + const fakeBlindedMessage = blindMessage(fakeSecret, r); + // construct DLEQ + const fakeDleq = createDLEQProof(fakeBlindedMessage.B_, privkey); + // blind signature + const fakeBlindSignature = createBlindSignature(fakeBlindedMessage.B_, privkey, 1, '00'); + // unblind + const proof = constructProofFromPromise(fakeBlindSignature, r, fakeSecret, pubkey); + // serialize + const serializedProof = { + ...serializeProof(proof), + dleq: { + r: numberToHexPadded64(r), + e: bytesToHex(fakeDleq.e), + s: bytesToHex(fakeDleq.s) + } + } as Proof; + + test('has valid dleq', () => { + const keyset = { + id: '00', + unit: 'sat', + keys: { [1]: pubkey.toHex(true) } + }; + const validDleq = hasValidDleq(serializedProof, keyset); + expect(validDleq).toBe(true); + }); + test('has valid dleq with no matching key', () => { + const keyset = { + id: '00', + unit: 'sat', + keys: { [2]: pubkey.toHex(true) } + }; + let exc; + try { + hasValidDleq(serializedProof, keyset); + } catch (e) { + exc = e; + } + expect(exc).toEqual(new Error('undefined key for amount 1')); + }); +});