mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
NUT-12: validate offline ecash DLEQ proofs (#198)
This commit is contained in:
@@ -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
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user