Validator errors can reveal private transaction input values
- Lingua principale
- Rust
- Stelle
- 104
- Fork
- 138
- Merge medio
- 1g 13h
- PR unite (30g)
- 56
Descrizione
## Scenario
A transaction request contains a public proven transaction and its sealed private inputs. The client seals those inputs for the validators so that the RPC node and sequencer cannot read them. This privacy rule is stated in the `ProvenTransaction` [message](https://github.com/0xMiden/node/blob/aa17a78798ff050778e7a14fdbfbb4873f38e6f4/proto/proto/types/transaction.proto#L11-L20).
The RPC node sends the request to every validator through the [validator fan-out code](https://github.com/0xMiden/node/blob/aa17a78798ff050778e7a14fdbfbb4873f38e6f4/crates/rpc/src/server/api.rs#L40-L55). Each validator opens its copy of the sealed inputs and turns the plaintext into `TransactionInputs`. The sequencer still cannot see that plaintext.
The privacy boundary fails when those inputs are invalid. For example, a client can send two copies of one private note because of a client bug or a hand-built request. The input decoder rejects the duplicate note and puts the note's full nullifier in its error. That nullifier is private and need not appear in the public proven transaction.
The validator places the complete decoding error in its gRPC status:
```rust
TransactionInputs::read_from_bytes(&plaintext).map_err(|err| {
Status::invalid_argument(err.as_report_context("Invalid transaction inputs"))
})
```
The RPC node receives that status from the validator. As a result, the same node that could not open the ciphertext can now read a private value taken from its plaintext.
```text
Invalid transaction inputs:
caused by: invalid value: transaction input note with nullifier 0x1aa9af44b8b02411438369f8556b4aad03e85e2fccba216c73826e1e6baf25ca is a duplicate
```
This error is produced during input decoding, before the validator checks the proof. Errors produced later during transaction execution are exposed in the same way through the [validator gRPC handler](https://github.com/0xMiden/node/blob/aa17a78798ff050778e7a14fdbfbb4873f38e6f4/bin/validator/src/server/validator_service/submit_proven_transaction.rs#L154-L170).
## Test
The test below proves that the returned nullifier is absent from the public proven transaction. It then puts two copies of the private note in the sealed inputs and checks that the validator returns that nullifier.
```rust
let private_nullifier = private_note.nullifier();
assert!(tx.input_notes().iter().all(|note| note.nullifier() != private_nullifier));
let mut duplicate_inputs = fixture.execution_failure_inputs.clone();
duplicate_inputs.set_input_notes(vec![private_note.clone(), private_note]);
let sealed = tv.seal(tx.id(), &duplicate_inputs.to_bytes());
let status = tv.call_submit_proven_transaction(tx, sealed).await.unwrap_err();
assert!(status.message().contains(&private_nullifier.to_string()));
assert!(!status.message().contains("proof verification"));
```
A client triggers the leak when its private inputs fail decoding or execution. The validator sends private details from those inputs back across the privacy boundary.
This does not let the sequencer probe an otherwise valid sealed witness. Changing the ciphertext makes authentication fail, while sealing a fresh payload exposes only the replacement inputs chosen by the sender.
Other input and execution errors can print note IDs, commitments, raw field values, recipient data, map roots, advice keys, source text, and assertion messages. Rendered VM errors also contain terminal color codes. Because the handler uses [miden_instrument(err)](https://github.com/0xMiden/node/blob/aa17a78798ff050778e7a14fdbfbb4873f38e6f4/bin/validator/src/server/validator_service/submit_proven_transaction.rs#L23-L27), these errors may also reach the OpenTelemetry sink.
There is a separate state leak in the same handler. It [returns success for an existing transaction ID before proof and execution checks](https://github.com/0xMiden/node/blob/aa17a78798ff050778e7a14fdbfbb4873f38e6f4/bin/validator/src/server/validator_service/submit_proven_transaction.rs#L48-L54). This response reveals whether one validator has stored that public transaction ID, but it does not read the stored private inputs.
## Suggested change
Return fixed gRPC errors for unsealing, input decoding, and validation. Keep the detailed cause inside the validator and do not attach it to exported spans.
Reproduction and full test
Start from commit `aa17a78798ff050778e7a14fdbfbb4873f38e6f4`.
```sh
git clone https://github.com/0xMiden/node.git
cd node
git checkout aa17a78798ff050778e7a14fdbfbb4873f38e6f4
```
Apply the test patch below.
```diff
diff --git a/bin/validator/src/server/validator_service/tests.rs b/bin/validator/src/server/validator_service/tests.rs
index 6b600c04..67155edc 100644
--- a/bin/validator/src/server/validator_service/tests.rs
+++ b/bin/validator/src/server/validator_service/tests.rs
@@ -1214,6 +1214,35 @@ async fn failed_reexecution_does_not_store_inputs() {
tv.assert_transaction_absent(tx.id(), 0).await;
}
+/// Transaction input decoding reflects a note nullifier that is absent from the public proven
+/// transaction.
+#[tokio::test]
+async fn duplicate_private_input_note_reflects_its_nullifier() {
+ let fixture = proven_transaction_fixture().await;
+ let tx = &fixture.transaction;
+ let private_note = fixture
+ .execution_failure_inputs
+ .input_notes()
+ .iter()
+ .next()
+ .unwrap()
+ .note()
+ .clone();
+ let private_nullifier = private_note.nullifier();
+ assert!(tx.input_notes().iter().all(|note| note.nullifier() != private_nullifier));
+
+ let mut duplicate_inputs = fixture.execution_failure_inputs.clone();
+ duplicate_inputs.set_input_notes(vec![private_note.clone(), private_note]);
+ let tv = TestValidator::new().await;
+ let sealed = tv.seal(tx.id(), &duplicate_inputs.to_bytes());
+
+ let status = tv.call_submit_proven_transaction(tx, sealed).await.unwrap_err();
+ eprintln!("sequencer-observed duplicate-note status: {}", status.message());
+ assert!(status.message().contains("Invalid transaction inputs"));
+ assert!(status.message().contains(&private_nullifier.to_string()));
+ assert!(!status.message().contains("proof verification"));
+}
+
/// A successful re-execution with a different header must not create a sealed record.
#[tokio::test]
async fn header_mismatch_does_not_store_inputs() {
```
Run the test with this command.
```sh
cargo nextest run --all-features -p miden-validator \
-E 'test(duplicate_private_input_note_reflects_its_nullifier)' \
--no-capture
```
The test passes and prints the status above. It was also run in a clean worktree at the listed commit. The full run took about two seconds after compilation.
The earlier gRPC proof of concept also passed the contribution guide's [`make lint` and `make test`](https://github.com/0xMiden/node/blob/aa17a78798ff050778e7a14fdbfbb4873f38e6f4/docs/internal/src/contributing.md#L28-L38) checks.
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.