Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(proto)!: update SignedTransaction to contain Any for transaction #1044

Merged
merged 10 commits into from
May 10, 2024

Conversation

noot
Copy link
Collaborator

@noot noot commented May 3, 2024

Summary

as title says

Background

protobuf encoding is not deterministic, so we want to verify the signature over the exact same bytes signed.

Changes

  • update SignedTransaction to contain Any for transaction instead of UnsignedTransaction

Testing

unit test

Breaking Changelist

  • the SignedTransaction type is changed
  • tx hashes are no longer the same

Related Issues

closes #310

@noot noot requested review from a team, joroshiba and SuperFluffy as code owners May 3, 2024 22:47
@github-actions github-actions bot added the proto pertaining to the Astria Protobuf spec label May 3, 2024
Copy link
Member

@SuperFluffy SuperFluffy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could amend the SignedTransaction type to contain the raw bytes that we signed over, i.e. do something like this:

struct SignedTransaction {
    signature: Signature,
    verification_key: VerificationKey,
    transaction: UnsignedTransaction,
    transaction_bytes: Bytes,
}

Because we don't allow mutation of the signed transaction we can ensure that the signature always stays valid.

pub fn into_any(self) -> pbjson_types::Any {
let raw = self.into_raw();
pbjson_types::Any {
type_url: UNSIGNED_TRANSACTION_TYPE_URL.to_string(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please prost::Name::type_url for this.

@@ -234,6 +246,15 @@ impl UnsignedTransaction {
}
}

#[must_use]
pub fn to_any(&self) -> pbjson_types::Any {
let raw = self.to_raw();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can impl this method in terms of into_any (there is no perf advantage right now I think in specializing it and it avoids dulicating code):

pub fn to_any(&self) -> pbjson_types::Any {
    self.clone().into_any()
}

/// - if the type URL is not the expected type URL
/// - if the bytes in the [`Any`] do not decode to an [`UnsignedTransaction`]
pub fn try_from_any(any: pbjson_types::Any) -> Result<Self, UnsignedTransactionError> {
if any.type_url != UNSIGNED_TRANSACTION_TYPE_URL {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we are deriving prost::Name for all types:

if any.type_url != UnsignedTransactionError::type_url() {

Comment on lines 330 to 331
#[error("invalid type URL")]
InvalidTypeUrl,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Useful to provide a bit more info:

Suggested change
#[error("invalid type URL")]
InvalidTypeUrl,
#[error("encountered invalid type URL when converting from `google.protobuf.Any`; got `{got}`, expected `{}`", UnsignedTransaction::type_url())]
InvalidTypeUrl { got: String, },

@@ -282,6 +327,10 @@ enum UnsignedTransactionErrorKind {
Action(#[source] action::ActionError),
#[error("`params` field is unset")]
UnsetParams(),
#[error("invalid type URL")]
InvalidTypeUrl,
#[error("invalid bytes in Any")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be a tad more specific about this. It might be that

Suggested change
#[error("invalid bytes in Any")]
#[error("failed to decode `google.protobuf.Any` to `{}`", UnsignedTransaction::type_url())]

#[error("invalid type URL")]
InvalidTypeUrl,
#[error("invalid bytes in Any")]
InvalidAnyBytes(#[source] prost::DecodeError),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
InvalidAnyBytes(#[source] prost::DecodeError),
DecodeAny(#[source] prost::DecodeError),

@@ -10,6 +10,9 @@ use super::raw;
pub mod action;
pub use action::Action;

pub const UNSIGNED_TRANSACTION_TYPE_URL: &str =
"/astria.protocol.transaction.v1alpha1.UnsignedTransaction";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not generally opposed to doing this (to save some allocations), but we must ensure that const matches the generated type URL of the prost::Name trait. So either use UnsignedTransaction::type_url() everywhere, or alternatively use the hardcoded value here, but add a unit test like below.

#[test]
fn hardcoded_type_url_matches_generated_trait() {
    assert_eq!(UNSIGNED_TRANSACTION_TYPE_URL, &UnsignedTransaction::type_url(),);
}

Read my other comments re the use of Name::type_url() with this in mind - either use the trait method everywhere, or ensure that the const always stays valid.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated to use type_url()

@noot
Copy link
Collaborator Author

noot commented May 8, 2024

@SuperFluffy addressed comments! what's the point of keeping the tx bytes inside the SignedTransaction? there isn't any case right now where we would need to re-verify the signature, as SignedTransaction can only be constructed if the signature was valid.

@noot noot requested a review from SuperFluffy May 8, 2024 17:13
@@ -95,7 +98,7 @@ impl SignedTransaction {
raw::SignedTransaction {
signature: signature.to_bytes().to_vec(),
public_key: verification_key.to_bytes().to_vec(),
transaction: Some(transaction.into_raw()),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the use case for calling into_raw or to_raw on a SignedTransaction? Since we don't persist the serialized bytes in try_from_raw we lose the determinism of the what is returned here. The signature may not match the transaction bytes on a roundtrip. If you deserialized from raw, reserializing into is not safe.

My thinking is we can either remove this if there isn't a use for them, or we should maintain a copy of the bytes of the transaction when creating SignedTransaction and then build and return the any object here. I know not as pretty, and in some usecases it would be fine (ie I just constructed a new transaction and signed it), but the loops wouldn't be safe and avoiding them ever happening would require we add some data saying like "derived from raw" and then not allow reserializing (assuming we aren't perfect at code review).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i updated this to keep the transaction_bytes in SignedTransaction so converting to raw is now guaranteed to be the same

@SuperFluffy
Copy link
Member

@noot

@SuperFluffy addressed comments! what's the point of keeping the tx bytes inside the SignedTransaction? there isn't any case right now where we would need to re-verify the signature, as SignedTransaction can only be constructed if the signature was valid.

My thinking is in similar vain to what @joroshiba had in their comment: it's not about the signature but about maintaining the exact same byte-representation when going SignedTransaction <> raw::SignedTransaction. Imagine this flow:

network bytes --1> raw Signed Transaction --2> checked Signed Transaction --3> raw Signed Transaction --4> network bytes

raw Signed Transaction contains UnsignedTransaction in the form of an Any.

Right now, if you do step 2, i.e. raw Signed Transaction --2> checked Signed Transaction, you throw away the original bytes that made up the unsigned transaction. So when you go checked Signed Transaction --3> raw Signed Transaction you re-encode you don't have a guarantee that you get the same bytes.

If you keep the original bytes around you can completely avoid the issue.

@noot
Copy link
Collaborator Author

noot commented May 9, 2024

@SuperFluffy that makes sense, added to SignedTransaction!

@noot noot added this pull request to the merge queue May 10, 2024
Merged via the queue into main with commit 171c765 May 10, 2024
36 checks passed
@noot noot deleted the noot/tx-protobuf branch May 10, 2024 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
proto pertaining to the Astria Protobuf spec
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Verifying signed transactions is not reliable because protobuf serialization is not deterministic
3 participants