NUT-12: validate offline ecash DLEQ proofs (#198)

This commit is contained in:
Keshav
2026-05-20 16:42:43 +02:00
committed by GitHub
parent afc1f0fbb6
commit a20044e4f7
5 changed files with 364 additions and 4 deletions
+233
View File
@@ -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,
)
})
})
+7 -1
View File
@@ -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',
},
}
+14 -2
View File
@@ -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)
+77
View File
@@ -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,
+33 -1
View File
@@ -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,