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

Add secp256r1 host function for signature verification #1376

Merged
merged 15 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/target
.vscode
/vendor
lcov.info
lcov.info
dmkozh marked this conversation as resolved.
Show resolved Hide resolved
.DS_Store
43 changes: 40 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ wasmparser = "=0.116.1"
[workspace.dependencies.stellar-xdr]
version = "=20.1.0"
git = "https://github.com/stellar/rs-stellar-xdr"
rev = "44b7e2d4cdf27a3611663e82828de56c5274cba0"
rev = "3a001b1fbb20e4cfa2cef2c0cc450564e8528057"
default-features = false

[workspace.dependencies.wasmi]
Expand Down
23 changes: 22 additions & 1 deletion soroban-env-common/env.json
Original file line number Diff line number Diff line change
Expand Up @@ -2000,7 +2000,28 @@
}
],
"return": "BytesObject",
"docs": "Recovers the SEC-1-encoded ECDSA secp256k1 public key that produced a given 64-byte signature over a given 32-byte message digest, for a given recovery_id byte."
"docs": "Recovers the SEC-1-encoded ECDSA secp256k1 public key that produced a given 64-byte `signature` over a given 32-byte `msg_digest` (produced by applying a secure cryptographic hash function on the message), for a given `recovery_id` byte. The `signature` is the ECDSA signature `(r, s)` serialized as fixed-size big endian scalar values, both `r`, `s` must be non-zero and `s` must be in the lower range. Returns a `BytesObject` containing 65-bytes representing SEC-1 encoded point in uncompressed format. The `recovery_id` is an integer value `0`, `1`, `2`, or `3`, the low bit (0/1) indicates the parity of the y-coordinate of the `public_key` (even/odd) and the high bit (3/4) indicate if the `r` (x-coordinate of `k x G`) has overflown during its computation."
},
{
"export": "3",
"name": "verify_sig_ecdsa_secp256r1",
"args": [
{
"name": "public_key",
"type": "BytesObject"
},
{
"name": "msg_digest",
"type": "BytesObject"
},
{
"name": "signature",
"type": "BytesObject"
}
],
"return": "Void",
"docs": "Verifies the `signature` using an ECDSA secp256r1 `public_key` on a 32-byte `msg_digest` (produced by applying a secure cryptographic hash function on the message). The `public_key` is expected to be 65 bytes in length, representing a SEC-1 encoded point in uncompressed format. The `signature` is the ECDSA signature `(r, s)` serialized as fixed-size big endian scalar values, both `r`, `s` must be non-zero and `s` must be in the lower range. ",
Copy link
Contributor

Choose a reason for hiding this comment

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

The description of msg_digest makes it sound like it's expected to be 32-bytes, when it can actually be any length right? The resulting hash of msg_digest is used in the host function, which is 32 bytes.

Copy link
Contributor

Choose a reason for hiding this comment

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

The same comment applies to the description for recover_key_ecdsa_secp256k1.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, the msg_digest is actually the hash of the message pre-applied, so it is 32-bytes.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah ok I misread hash_from_bytesobj_input. So those 32 bytes be anything provided by the user? The security warning here says that could be an issue - https://docs.rs/signature/2.1.0/signature/hazmat/trait.PrehashVerifier.html#-security-warning. Did you happen to look into this?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh I'm guessing it's the users responsibility to make sure msg_digest is the result of a secure cryptographic hash function? Maybe we should mention this in the description.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cool, thanks all for your inputs! It sounds like the best way forward is, for now have two interfaces at the SDK:

  • A secure one which accepts the full message: BytesN with a hash choice.
  • A non-secure one marked "hazmat" (with a dangerous looking warning) that accepts msg_digest: BytesN<32>

We could later (independent of this work) adapt the custom account interface to accept in its signature payload, a new CustomAccountPayload type (transparent wrapper of BytesN<32>), and support this type in the secure interface, so we can possibly deprecate the "hazmat" one.

Copy link
Member

Choose a reason for hiding this comment

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

Got it. The CustomAccountPayload will need to be a Val compatible type to sit on the function interface boundary, so it'll either need to be a first-class Val type, or one we fake to a degree in the SDK by providing our own type and implementations of the necessary traits. If we do that latter, it won't prevent someone from accepting a BytesN<32> instead of the specialised type.

We could benefit from that previous data design that @graydon had proposed where Bytes could be annotated with additional information that was persisted. The annotation in this case would indicate that the bytes was produced by a sha256 op. The annotations would be a way for us to, at the env level, provide a real guarantee that any bytes provided had been generated by a local sha256 op. It would allow developers to do things like hash a payload and store the hash, then in a separate invocation accept a signature and verify it signs for that stored hash.

Copy link
Contributor

Choose a reason for hiding this comment

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

so it'll either need to be a first-class Val type, or one we fake to a degree in the SDK by providing our own type and implementations of the necessary traits.

I'm not sure it's worth updating the protocol in order to introduce a new, narrow-use type. So I would rather go with just a new SDK type that behaves like BytesN<32>, but can only be used in this specific place. Does it prevent all the possible footguns? Not really. But does it provide safe usage patterns for the regular SDK users? I think it does.
Something involving more protocol changes like annotations, or a separate type could be considered for the future protocols. OTOH the current host function will stay in the interface anyway, so if we're too concerned about the vulnerability, then we should remove it from protocol 21, or spend more time on the supporting protocol changes.
From my understanding it seems like exploit is unlikely for a contract that doesn't already use this function incorrectly. So I believe that SDK-only harness provides sufficient balance between the necessary implementation effort and encouraging the proper function usage.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There are two separate independent discussions here 1. which interfaces we make available to the user in the SDK and 2. what's the best practices for a custom account contract to adapt to such interfaces. We should avoid mixing them together.

The host provides (in this PR) the raw cryptographic primitives for secp256r1 verification, that is well-defined and agreed-upon (in this PR discussion, in the CAP, and the CAP discussion), we are not going to change it (definitely not for P21, hopefully not for a long time).

The SDK bundles the primitives (secp256r1 verification and compute hash) and presents the users with two interfaces: standard and hazmat (described above #1376 (comment)). I have a rough sketch of it in the SDK.

The custom account contract can pick either of the two interfaces. If it picks the standard one, then the signature_payload will be hashed again, basically disregarding the fact that it is a hash and instead treat it as an opaque payload (which is already what its name suggests). Or it uses the hazmat one, treating (taking the promise from the host that) the signature_payload has already been hashed, this approach is also not wrong because the hashing is already baked into the host as part of the protocol, and __check_auth can only be called from the host, not by any random contract.

Either of the two approaches is okay from correctness and safety perspectives. And the discussion around designing a BytesN<32> wrapper is purely an add-on, extra hint for the user, making it even harder to use the interface wrong. But it is not required for the correctness of the interface, nor should it require significant changes to the existing env typing or protocol to accommodate it. We should discuss the wrapper type and custom account adaptation in a separate issue (or in the SDK PR linked above) to prevent this thread from ever-growing.

Copy link
Member

Choose a reason for hiding this comment

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

Continuing discussion about the SDK interface over on the SDK PR here:

"min_supported_protocol": 21
}
]
},
Expand Down
12 changes: 10 additions & 2 deletions soroban-env-host/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ num-traits = "=0.2.17"
num-integer = "=0.1.45"
num-derive = "=0.4.1"
backtrace = { version = "=0.3.69", optional = true }
k256 = {version = "=0.13.1", features=["ecdsa", "arithmetic"]}
k256 = {version = "=0.13.1", default-features = false, features = ["ecdsa", "arithmetic"]}
p256 = {version = "=0.13.2", default-features = false, features = ["ecdsa", "arithmetic"]}
ecdsa = {version = "=0.16.8", default-features = false}
sec1 = {version = "=0.7.3"}
elliptic-curve ={ version = "0.13.6", default-features = false}
generic-array ={ version = "0.14.7"}
# NB: getrandom is a transitive dependency of k256 which we're not using directly
# but we have to specify it here in order to enable its 'js' feature which
# is needed to build the host for wasm (a rare but supported config).
Expand Down Expand Up @@ -83,11 +88,14 @@ lstsq = "=0.5.0"
nalgebra = { version = "=0.32.3", default-features = false, features = ["std"]}
wasm-encoder = "=0.36.2"
rustversion = "1.0"
wycheproof = "=0.5.1"
k256 = {version = "=0.13.1", default-features = false, features = ["alloc"]}
p256 = {version = "=0.13.2", default-features = false, features = ["alloc"]}

[dev-dependencies.stellar-xdr]
version = "=20.1.0"
git = "https://github.com/stellar/rs-stellar-xdr"
rev = "44b7e2d4cdf27a3611663e82828de56c5274cba0"
rev = "3a001b1fbb20e4cfa2cef2c0cc450564e8528057"
default-features = false
features = ["arbitrary"]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use crate::common::HostCostMeasurement;
use ecdsa::signature::hazmat::PrehashSigner;
use elliptic_curve::scalar::IsHigh;
use k256::{
ecdsa::{Signature, SigningKey},
Secp256k1,
};
use rand::{rngs::StdRng, RngCore};
use soroban_env_host::{
cost_runner::{DecodeEcdsaCurve256SigRun, DecodeEcdsaCurve256SigSample},
Host,
};

pub(crate) struct DecodeEcdsaCurve256SigMeasure;

impl HostCostMeasurement for DecodeEcdsaCurve256SigMeasure {
type Runner = DecodeEcdsaCurve256SigRun<Secp256k1>;

fn new_random_case(
_host: &Host,
rng: &mut StdRng,
_input: u64,
) -> DecodeEcdsaCurve256SigSample {
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let signer = SigningKey::from_bytes(&key_bytes.into()).unwrap();
let mut msg_hash = [0u8; 32];
rng.fill_bytes(&mut msg_hash);
let mut sig: Signature = signer.sign_prehash(&msg_hash).unwrap();
// in our host implementation, we are rejecting high `S`. so here we
// normalize it to the low S before sending the result
if bool::from(sig.s().is_high()) {
sig = sig.normalize_s().unwrap();
}
DecodeEcdsaCurve256SigSample {
bytes: sig.to_vec(),
}
}
}
14 changes: 14 additions & 0 deletions soroban-env-host/benches/common/cost_types/mod.rs
Original file line number Diff line number Diff line change
@@ -1,34 +1,48 @@
#[cfg(not(feature = "next"))]
mod compute_ecdsa_secp256k1_sig;
mod compute_ed25519_pubkey;
mod compute_keccak256_hash;
mod compute_sha256_hash;
#[cfg(feature = "next")]
mod decode_ecdsa_curve256_sig;
mod host_mem_alloc;
mod host_mem_cmp;
mod host_mem_cpy;
mod invoke;
mod num_ops;
mod prng;
mod recover_ecdsa_secp256k1_key;
#[cfg(feature = "next")]
mod sec1_decode_point_uncompressed;
mod val_deser;
mod val_ser;
#[cfg(feature = "next")]
mod verify_ecdsa_secp256r1_sig;
mod verify_ed25519_sig;
mod visit_object;
mod vm_ops;
mod wasm_insn_exec;

#[cfg(not(feature = "next"))]
pub(crate) use compute_ecdsa_secp256k1_sig::*;
pub(crate) use compute_ed25519_pubkey::*;
pub(crate) use compute_keccak256_hash::*;
pub(crate) use compute_sha256_hash::*;
#[cfg(feature = "next")]
pub(crate) use decode_ecdsa_curve256_sig::*;
pub(crate) use host_mem_alloc::*;
pub(crate) use host_mem_cmp::*;
pub(crate) use host_mem_cpy::*;
pub(crate) use invoke::*;
pub(crate) use num_ops::*;
pub(crate) use prng::*;
pub(crate) use recover_ecdsa_secp256k1_key::*;
#[cfg(feature = "next")]
pub(crate) use sec1_decode_point_uncompressed::*;
pub(crate) use val_deser::*;
pub(crate) use val_ser::*;
#[cfg(feature = "next")]
pub(crate) use verify_ecdsa_secp256r1_sig::*;
pub(crate) use verify_ed25519_sig::*;
pub(crate) use visit_object::*;
pub(crate) use vm_ops::*;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use crate::common::HostCostMeasurement;
use p256::ecdsa::SigningKey;
use rand::{rngs::StdRng, RngCore};
use soroban_env_host::{
cost_runner::{Sec1DecodePointSample, Sec1DecodePointUncompressedRun},
Host,
};

pub(crate) struct Sec1DecodePointUncompressedMeasure {}

impl HostCostMeasurement for Sec1DecodePointUncompressedMeasure {
type Runner = Sec1DecodePointUncompressedRun;

fn new_random_case(_host: &Host, rng: &mut StdRng, _input: u64) -> Sec1DecodePointSample {
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let signer = SigningKey::from_bytes(&key_bytes.into()).unwrap();
let verifying_key = signer.verifying_key();
let bytes = verifying_key
.to_encoded_point(false /* compress */)
.to_bytes();
Sec1DecodePointSample { bytes }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use crate::common::HostCostMeasurement;
use ecdsa::signature::hazmat::PrehashSigner;
use elliptic_curve::scalar::IsHigh;
use p256::ecdsa::{Signature, SigningKey};
use rand::{rngs::StdRng, RngCore};
use soroban_env_host::{
cost_runner::{VerifyEcdsaSecp256r1SigRun, VerifyEcdsaSecp256r1SigSample},
xdr::Hash,
Host,
};

pub(crate) struct VerifyEcdsaSecp256r1SigMeasure {}

impl HostCostMeasurement for VerifyEcdsaSecp256r1SigMeasure {
type Runner = VerifyEcdsaSecp256r1SigRun;

fn new_random_case(
_host: &Host,
rng: &mut StdRng,
_input: u64,
) -> VerifyEcdsaSecp256r1SigSample {
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let signer = SigningKey::from_bytes(&key_bytes.into()).unwrap();
let mut msg_hash = [0u8; 32];
rng.fill_bytes(&mut msg_hash);
let mut sig: Signature = signer.sign_prehash(&msg_hash).unwrap();
// in our host implementation, we are rejecting high `s`, we are doing it here too.
if bool::from(sig.s().is_high()) {
sig = sig.normalize_s().unwrap()
}
VerifyEcdsaSecp256r1SigSample {
pub_key: signer.verifying_key().clone(),
msg_hash: Hash::from(msg_hash),
sig,
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use crate::common::HostCostMeasurement;
use ecdsa::signature::hazmat::PrehashSigner;
use elliptic_curve::scalar::IsHigh;
use rand::{rngs::StdRng, RngCore};
use soroban_env_host::{
cost_runner::{DecodeEcdsaCurve256SigSample, DecodeSecp256r1SigRun},
Host,
};

pub(crate) struct DecodeSecp256r1SigMeasure {}

impl HostCostMeasurement for DecodeSecp256r1SigMeasure {
type Runner = DecodeSecp256r1SigRun;

fn new_random_case(
_host: &Host,
rng: &mut StdRng,
_input: u64,
) -> DecodeEcdsaCurve256SigSample {
use p256::ecdsa::{Signature, SigningKey};

let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let signer = SigningKey::from_bytes(&key_bytes.into()).unwrap();
let mut msg_hash = [0u8; 32];
rng.fill_bytes(&mut msg_hash);
let mut sig: Signature = signer.sign_prehash(&msg_hash).unwrap();
// in our host implementation, we are rejecting high `s`, we are doing it here too.
if bool::from(sig.s().is_high()) {
sig = sig.normalize_s().unwrap();
}
DecodeEcdsaCurve256SigSample {
bytes: sig.to_vec(),
}
}
}
Loading
Loading