verify precomputed outputs match bip375 proofs

This commit is contained in:
Craig Raw 2026-05-24 10:22:46 +02:00
parent 20670b9b7d
commit e96aa0d3f8
2 changed files with 81 additions and 9 deletions

View File

@ -13,6 +13,7 @@ import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTOutput;
import com.sparrowwallet.drongo.psbt.PSBTProofException;
import com.sparrowwallet.drongo.silentpayments.*;
import java.nio.charset.StandardCharsets;
@ -1878,12 +1879,50 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
public List<SilentPayment> computeSilentPaymentOutputs(PSBT psbt, Map<PSBTInput, WalletNode> signingNodes) throws InvalidSilentPaymentException {
List<PSBTOutput> silentOutputs = psbt.getPsbtOutputs().stream().filter(psbtOutput -> psbtOutput.getSilentPaymentAddress() != null).collect(Collectors.toList());
if(!silentOutputs.isEmpty()) {
if(psbt.getPsbtInputs().size() != signingNodes.size()) {
throw new InvalidSilentPaymentException("All inputs must be from wallet to calculate silent payment addresses");
}
if(silentOutputs.isEmpty()) {
return Collections.emptyList();
}
List<SilentPayment> silentPayments = silentOutputs.stream()
if(psbt.getPsbtInputs().size() != signingNodes.size()) {
throw new InvalidSilentPaymentException("All inputs must be from wallet to calculate silent payment addresses");
}
List<PSBTOutput> preComputedOutputs = new ArrayList<>();
List<PSBTOutput> computeOutputs = new ArrayList<>();
for(PSBTOutput silentOutput : silentOutputs) {
Script script = silentOutput.getScript();
if(script != null && script.getToAddress() != null) {
preComputedOutputs.add(silentOutput);
} else {
computeOutputs.add(silentOutput);
}
}
List<SilentPayment> results = new ArrayList<>();
if(!preComputedOutputs.isEmpty()) {
Map<TransactionInput, ECKey> inputPublicKeys = new LinkedHashMap<>();
Transaction transaction = psbt.getTransaction();
for(int i = 0; i < psbt.getPsbtInputs().size(); i++) {
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
ECKey publicKey = SilentPaymentUtils.getInputPublicKey(signingNodes.get(psbtInput));
if(publicKey == null) {
throw new InvalidSilentPaymentException("Cannot derive input public key for silent payment verification");
}
inputPublicKeys.put(transaction.getInputs().get(i), publicKey);
}
try {
psbt.validateSilentPayments(inputPublicKeys);
} catch(PSBTProofException e) {
throw new InvalidSilentPaymentException("Silent payment metadata in PSBT failed BIP-375 verification: " + e.getMessage());
}
for(PSBTOutput silentOutput : preComputedOutputs) {
results.add(new SilentPayment(silentOutput.getSilentPaymentAddress(), silentOutput.getScript().getToAddress(), null, silentOutput.getAmount(), false));
}
}
if(!computeOutputs.isEmpty()) {
List<SilentPayment> silentPayments = computeOutputs.stream()
.map(psbtOutput -> new SilentPayment(psbtOutput.getSilentPaymentAddress(), null, psbtOutput.getAmount(), false)).collect(Collectors.toList());
Map<HashIndex, WalletNode> utxos = signingNodes.keySet().stream()
.collect(Collectors.toMap(psbtInput -> new HashIndex(psbtInput.getPrevTxid(), psbtInput.getPrevIndex()), signingNodes::get));
@ -1893,8 +1932,8 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
psbt.getSilentPaymentsDLEQProofs().put(key, ecdhShareAndProof.dleqProof());
});
for(int i = 0; i < silentOutputs.size(); i++) {
PSBTOutput silentOutput = silentOutputs.get(i);
for(int i = 0; i < computeOutputs.size(); i++) {
PSBTOutput silentOutput = computeOutputs.get(i);
SilentPayment silentPayment = silentPayments.get(i);
if(!silentPayment.isAddressComputed()) {
throw new InvalidSilentPaymentException("Silent payment address was not calculated");
@ -1904,10 +1943,10 @@ public class Wallet extends Persistable implements Comparable<Wallet> {
silentOutput.setScript(outputScript);
}
return silentPayments;
results.addAll(silentPayments);
}
return Collections.emptyList();
return results;
}
public void finalise(PSBT psbt) {

View File

@ -1117,6 +1117,39 @@ public class SilentPaymentUtilsTest {
Assertions.assertTrue(verified.isEmpty(), "Missing DLEQ proof must cause rejection");
}
@Test
public void testComputeRejectsInjectedSpInfo() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
byte[] baitXOnly = new byte[32];
new SecureRandom().nextBytes(baitXOnly);
Script baitScript = ScriptType.P2TR.getOutputScript(baitXOnly);
psbt.getPsbtOutputs().getFirst().setScript(baitScript);
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
Assertions.assertThrows(InvalidSilentPaymentException.class, () -> wallet.computeSilentPaymentOutputs(psbt, signingNodes),
"Injected PSBT_OUT_SP_V0_INFO without valid BIP-375 proofs must abort signing");
Assertions.assertArrayEquals(baitScript.getProgram(), psbt.getPsbtOutputs().get(0).getScript().getProgram(),
"Visible output script must not be mutated when SP metadata fails verification");
}
@Test
public void testComputePreservesScriptForVerifiedPsbt() throws Exception {
Wallet wallet = buildVerifyWallet();
WalletNode receiveNode = primeReceiveNode(wallet, 0);
PSBT psbt = buildVerifySendingPsbt(receiveNode);
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
wallet.computeSilentPaymentOutputs(psbt, signingNodes);
byte[] computedScript = psbt.getPsbtOutputs().getFirst().getScript().getProgram();
wallet.computeSilentPaymentOutputs(psbt, signingNodes);
Assertions.assertArrayEquals(computedScript, psbt.getPsbtOutputs().getFirst().getScript().getProgram(),
"Already-computed and proof-backed SP output script must be preserved by a subsequent compute call");
}
private static Wallet buildVerifyWallet() throws Exception {
DeterministicSeed seed = new DeterministicSeed(VERIFY_TEST_SEED, "", 0, DeterministicSeed.Type.BIP39);
Wallet wallet = new Wallet();