From a20044e4f7fadd5bf0571687934623bdb8595b79 Mon Sep 17 00:00:00 2001 From: Keshav <144454324+keshav0479@users.noreply.github.com> Date: Wed, 20 May 2026 20:12:43 +0530 Subject: [PATCH] NUT-12: validate offline ecash DLEQ proofs (#198) --- __tests__/cashuDleq.test.ts | 233 +++++++++++++++++++++++++++++ jest.config.js | 8 +- src/screens/ReceiveScreen.tsx | 16 +- src/services/cashu/cashuUtils.ts | 77 ++++++++++ src/services/wallet/receiveTask.ts | 34 ++++- 5 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 __tests__/cashuDleq.test.ts diff --git a/__tests__/cashuDleq.test.ts b/__tests__/cashuDleq.test.ts new file mode 100644 index 00000000..c512ca1d --- /dev/null +++ b/__tests__/cashuDleq.test.ts @@ -0,0 +1,233 @@ +import { + Amount, + blindMessage, + constructUnblindedSignature, + createBlindSignature, + createDLEQProof, + getPubKeyFromPrivKey, + pointFromBytes, +} from '@cashu/cashu-ts' +import type {MintKeys, Proof} from '@cashu/cashu-ts' +import {bytesToHex, hexToBytes} from '@noble/curves/utils.js' + +jest.mock('../src/services/logService', () => ({ + log: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }, +})) + +jest.mock('../src/services/nostrService', () => ({ + NostrClient: { + getFirstTagValue: jest.fn(), + }, +})) + +import {CashuUtils} from '../src/services/cashu/cashuUtils' +import AppError from '../src/utils/AppError' + +const invalidDleqMessage = 'Offline ecash verification failed. Do not accept this token.' + +const hexToNumber = (hex: string): bigint => BigInt(`0x${hex}`) +const numberToHexPadded64 = (scalar: bigint): string => scalar.toString(16).padStart(64, '0') + +const makeProofFixture = function (): {proof: Proof; mintKeys: MintKeys[]} { + const privkey = hexToBytes('1'.padStart(64, '0')) + const pubkey = pointFromBytes(getPubKeyFromPrivKey(privkey)) + const secret = new TextEncoder().encode('fakeSecret') + const r = hexToNumber('123456'.padStart(64, '0')) + const blindedMessage = blindMessage(secret, r) + const dleq = createDLEQProof(blindedMessage.B_, privkey) + const blindSignature = createBlindSignature(blindedMessage.B_, privkey, '00') + const unblinded = constructUnblindedSignature(blindSignature, r, secret, pubkey) + + const proof: Proof = { + id: unblinded.id, + amount: Amount.from(1), + C: unblinded.C.toHex(true), + secret: new TextDecoder().decode(unblinded.secret), + dleq: { + r: numberToHexPadded64(r), + e: bytesToHex(dleq.e), + s: bytesToHex(dleq.s), + }, + } + + const mintKeys: MintKeys[] = [ + { + id: unblinded.id, + unit: 'sat', + keys: { + '1': pubkey.toHex(true), + }, + }, + ] + + return {proof, mintKeys} +} + +const expectValidationError = function ( + run: () => void, + message: string, +) { + expect(run).toThrow(AppError) + + try { + run() + } catch (e: any) { + expect(e.message).toBe(message) + } +} + +describe('CashuUtils.verifyProofsDleqOrThrow', () => { + it('accepts a proof with valid DLEQ', () => { + const {proof, mintKeys} = makeProofFixture() + + expect(() => CashuUtils.verifyProofsDleqOrThrow([proof], mintKeys)).not.toThrow() + }) + + it('rejects a proof without DLEQ', () => { + const {proof, mintKeys} = makeProofFixture() + const proofWithoutDleq: Proof = {...proof} + delete proofWithoutDleq.dleq + + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([proofWithoutDleq], mintKeys), + 'This token does not include offline verification proof. Receive it online instead.', + ) + }) + + it('rejects a proof without the DLEQ blinding factor', () => { + const {proof, mintKeys} = makeProofFixture() + const proofWithoutR: Proof = { + ...proof, + dleq: { + e: proof.dleq!.e, + s: proof.dleq!.s, + }, + } + + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([proofWithoutR], mintKeys), + 'This token is missing the DLEQ blinding factor needed for offline verification.', + ) + }) + + it('rejects a proof when the keyset is not cached', () => { + const {proof} = makeProofFixture() + + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([proof], []), + 'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.', + ) + }) + + it('rejects a proof when the cached keyset does not have the amount key', () => { + const {proof, mintKeys} = makeProofFixture() + const keysWithoutAmount: MintKeys[] = [ + { + ...mintKeys[0], + keys: { + '2': mintKeys[0].keys['1'], + }, + }, + ] + + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([proof], keysWithoutAmount), + 'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.', + ) + }) + + it('rejects a proof with tampered DLEQ e', () => { + const {proof, mintKeys} = makeProofFixture() + const tamperedProof: Proof = { + ...proof, + dleq: { + ...proof.dleq!, + e: '00'.repeat(32), + }, + } + + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([tamperedProof], mintKeys), + invalidDleqMessage, + ) + }) + + it('rejects a proof with tampered DLEQ s', () => { + const {proof, mintKeys} = makeProofFixture() + const tamperedProof: Proof = { + ...proof, + dleq: { + ...proof.dleq!, + s: '00'.repeat(32), + }, + } + + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([tamperedProof], mintKeys), + invalidDleqMessage, + ) + }) + + it('rejects a proof with tampered DLEQ r', () => { + const {proof, mintKeys} = makeProofFixture() + const tamperedProof: Proof = { + ...proof, + dleq: { + ...proof.dleq!, + r: '01'.repeat(32), + }, + } + + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([tamperedProof], mintKeys), + invalidDleqMessage, + ) + }) + + it('rejects a proof with tampered signature C', () => { + const {proof, mintKeys} = makeProofFixture() + const tamperedC = `${proof.C.startsWith('02') ? '03' : '02'}${proof.C.slice(2)}` + const tamperedProof: Proof = { + ...proof, + C: tamperedC, + } + + expect(tamperedProof.C).not.toBe(proof.C) + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([tamperedProof], mintKeys), + invalidDleqMessage, + ) + }) + + it('rejects a proof with tampered secret', () => { + const {proof, mintKeys} = makeProofFixture() + const tamperedProof: Proof = { + ...proof, + secret: `${proof.secret}-tampered`, + } + + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([tamperedProof], mintKeys), + invalidDleqMessage, + ) + }) + + it('rejects a multi-proof token when one proof has invalid DLEQ', () => { + const {proof, mintKeys} = makeProofFixture() + const tamperedProof: Proof = { + ...proof, + secret: `${proof.secret}-tampered`, + } + + expectValidationError( + () => CashuUtils.verifyProofsDleqOrThrow([proof, tamperedProof], mintKeys), + invalidDleqMessage, + ) + }) +}) diff --git a/jest.config.js b/jest.config.js index 8eb675e9..aac6616f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,9 @@ module.exports = { preset: 'react-native', -}; + transformIgnorePatterns: [ + 'node_modules/(?!((jest-)?react-native|@react-native|@react-native-community|@cashu|@noble|@scure)/)', + ], + moduleNameMapper: { + '^@noble/hashes/utils$': '@noble/hashes/utils.js', + }, +} diff --git a/src/screens/ReceiveScreen.tsx b/src/screens/ReceiveScreen.tsx index 7b4a1f91..19d6b0c4 100644 --- a/src/screens/ReceiveScreen.tsx +++ b/src/screens/ReceiveScreen.tsx @@ -27,7 +27,7 @@ import {CashuUtils} from '../services/cashu/cashuUtils' import {ResultModalInfo} from './Wallet/ResultModalInfo' import {MintListItem} from './Mints/MintListItem' import useIsInternetReachable from '../utils/useIsInternetReachable' -import { CurrencyCode, MintUnit, getCurrency } from "../services/wallet/currency" +import { CurrencyCode, MintUnit, MintUnits, getCurrency } from "../services/wallet/currency" import { MintHeader } from './Mints/MintHeader' import numbro from 'numbro' import { TranItem } from './TranDetailScreen' @@ -143,6 +143,10 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) { throw new AppError(Err.VALIDATION_ERROR, translate("decodedMissingCurrencyUnit", { unit: CurrencyCode.SAT })) } + if(!MintUnits.includes(unit as MintUnit)) { + throw new AppError(Err.VALIDATION_ERROR, `Wallet does not support ${unit} unit.`) + } + if(!mintUrl) { throw new AppError(Err.VALIDATION_ERROR, 'Decoded token is missing mint url') } @@ -156,7 +160,15 @@ export const ReceiveScreen = observer(function ReceiveScreen({ route }: Props) { isLockedToWallet = lockedToPK === '02' + keys.NOSTR.publicKey } - log.trace('decoded tokenMetadata', {tokenInfo, isLocked, isLockedToWallet}) + log.trace('decoded tokenMetadata', { + mint: tokenInfo.mint, + unit: tokenInfo.unit, + amount: tokenInfo.amount.toString(), + proofCount: tokenInfo.incompleteProofs?.length ?? 0, + hasMemo: !!tokenInfo.memo, + isLocked, + isLockedToWallet, + }) log.trace('tokenAmount', {amount, unit}) const currency = getCurrency(unit as MintUnit) diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index 6bf23907..e7b3ad5a 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -2,10 +2,13 @@ import {Mint} from '../../models/Mint' import { Amount, OutputData, + hasValidDleq, } from '@cashu/cashu-ts' import type { Token, + Proof as CashuDecodedProof, ProofLike as CashuProof, + MintKeys as CashuMintKeys, PaymentRequest as CashuPaymentRequest, PaymentRequestPayload, TokenMetadata, @@ -257,6 +260,79 @@ const getProofsSubset = function ( return proofs.filter(proof => !proofsToRemove.some(p => p.secret === proof.secret)) } +const verifyProofsDleqOrThrow = function ( + proofs: CashuDecodedProof[], + mintKeys: CashuMintKeys[], +): void { + if (!proofs || proofs.length === 0) { + throw new AppError( + Err.VALIDATION_ERROR, + 'This token does not contain ecash proofs to verify offline.', + { caller: 'verifyProofsDleqOrThrow' }, + ) + } + + if (!mintKeys || mintKeys.length === 0) { + throw new AppError( + Err.VALIDATION_ERROR, + 'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.', + { caller: 'verifyProofsDleqOrThrow' }, + ) + } + + for (const [proofIndex, proof] of proofs.entries()) { + const amount = proof.amount.toString() + const params = { + caller: 'verifyProofsDleqOrThrow', + proofIndex, + keysetId: proof.id, + amount, + } + + const keyset = mintKeys.find(k => k.id === proof.id) + + if (!keyset || !keyset.keys || !keyset.keys[amount]) { + throw new AppError( + Err.VALIDATION_ERROR, + 'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.', + params, + ) + } + + if (!proof.dleq) { + throw new AppError( + Err.VALIDATION_ERROR, + 'This token does not include offline verification proof. Receive it online instead.', + params, + ) + } + + if (!proof.dleq.r) { + throw new AppError( + Err.VALIDATION_ERROR, + 'This token is missing the DLEQ blinding factor needed for offline verification.', + params, + ) + } + + let isValid = false + + try { + isValid = hasValidDleq(proof, keyset) + } catch { + isValid = false + } + + if (!isValid) { + throw new AppError( + Err.VALIDATION_ERROR, + 'Offline ecash verification failed. Do not accept this token.', + params, + ) + } + } +} + const validateMintKeys = function (keys: object): boolean { let isValid = true @@ -458,6 +534,7 @@ export const CashuUtils = { getProofsToSend, exportProofs, getProofsSubset, + verifyProofsDleqOrThrow, validateMintKeys, getMintFromProof, getP2PKPubkeySecret, diff --git a/src/services/wallet/receiveTask.ts b/src/services/wallet/receiveTask.ts index e07f8655..b5f114ba 100644 --- a/src/services/wallet/receiveTask.ts +++ b/src/services/wallet/receiveTask.ts @@ -170,7 +170,7 @@ export const receiveOfflinePrepareTask = async function ( ) { const transactionData: TransactionData[] = [] let transaction: Transaction | undefined = undefined - const mintToReceive = mintUrl + const mintToReceive = mintUrl.replace(/\/$/, '') try { @@ -219,6 +219,38 @@ export const receiveOfflinePrepareTask = async function ( } as unknown as TransactionTaskResult } + const mintInstance = mintsStore.findByUrl(mintToReceive) + + if (!mintInstance) { + throw new AppError( + Err.VALIDATION_ERROR, + 'This token cannot be verified offline because the mint is not saved in your wallet. Go online to add the mint or receive it online.', + {caller: 'receiveOfflinePrepareTask', mintUrl: mintToReceive} + ) + } + + if (!mintInstance.keysetIds || mintInstance.keysetIds.length === 0 || !mintInstance.keys || mintInstance.keys.length === 0) { + throw new AppError( + Err.VALIDATION_ERROR, + 'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.', + {caller: 'receiveOfflinePrepareTask', mintUrl: mintToReceive} + ) + } + + let token: Token + + try { + token = getDecodedToken(encodedToken, mintInstance.keysetIds) + } catch (e: any) { + throw new AppError( + Err.VALIDATION_ERROR, + 'Could not decode this ecash token for offline verification.', + {caller: 'receiveOfflinePrepareTask', mintUrl: mintToReceive, reason: e?.message} + ) + } + + CashuUtils.verifyProofsDleqOrThrow(token.proofs, mintInstance.keys) + // Update transaction status transactionData.push({ status: TransactionStatus.PREPARED_OFFLINE,