Skip to content

Restrict usage of unconstrained functions to within unsafe blocks #4442

Description

@TomAFrench

Problem

It's currently not obvious when we're calling an unconstrained function (and introducing unconstrained values into the circuit). It's then quite difficult to rule out that any under-constrained bugs exist as any function call is a potential source.

This would be easier if we could limit the potential surface for this class of bugs such that Noir developers only need to worry about them when dealing with unconstrained functions and the compiler will flag up when this is the case.

Potential Solution

Noir code which makes use of unconstrained functions can be thought of as analogous to unsafe Rust. In Rust, unsafe blocks can be used to temporarily allow breaking some of the compiler's guarantees around memory safety before passing a value back to safe Rust which can be handled similarly to any other safe value (assuming that the code ran in the unsafe block is correct).

If we map this concept over to Noir, we can temporarily break the compiler guarantee that the prover will be constrained to follow the specified logic. Like in unsafe Rust, if we were to write entirely safe code in an unsafe block then it's equivalent to if the block was safe, so an unsafe block in Noir should still lay down constraints except it should also allow introducing unsafe values to the circuit (through unconstrained function calls).

An example of how it can be used

fn is_valid_impl(context: &mut PrivateContext, message_field: Field) -> pub bool {
    // Load public key from storage
    let storage = Storage::init(Context::private(context));
    let public_key = storage.public_key.get_note();

    // Load auth witness
    let witness: [Field; 64] = get_auth_witness(message_field);
    let mut signature: [u8; 64] = [0; 64];
    for i in 0..64 {
        signature[i] = witness[i] as u8;
    }

    // Verify payload signature using Ethereum's signing scheme
    // Note that noir expects the hash of the message/challenge as input to the ECDSA verification.
    let hashed_message: [u8; 32] = std::hash::sha256(message_field.to_be_bytes(32));
    let verification = std::ecdsa_secp256k1::verify_signature(public_key.x, public_key.y, signature, hashed_message);
    assert(verification == true);

    true
}

With the addition of unsafe blocks this could look something like:

fn is_valid_impl(context: &mut PrivateContext, message_field: Field) -> pub bool {
    // Load public key from storage
    let storage = Storage::init(Context::private(context));
    let public_key = storage.public_key.get_note();

    // Note that noir expects the hash of the message/challenge as input to the ECDSA verification.
    let hashed_message: [u8; 32] = std::hash::sha256(message_field.to_be_bytes(32));

    unsafe {
        // Safety: Prover must provide an ECDSA signature over `hashed_message` which matches the stored public key.
        
        // Load auth witness
        let witness: [Field; 64] = get_auth_witness(message_field);
        let mut signature: [u8; 64] = [0; 64];
        for i in 0..64 {
            signature[i] = witness[i] as u8;
        }

        // Verify payload signature using Ethereum's signing scheme
        let verification = std::ecdsa_secp256k1::verify_signature(public_key.x, public_key.y, signature, hashed_message);
        assert(verification == true);
    };

    true
}

Similarly to in Rust we could add a lint which requires that unsafe blocks must contain a safety comment which explains why the code is sound.


I've got a messy implementation of this in #4429 which adds an unsafe flag to blocks and then during type checking we throw an error if we perform an unconstrained function call inside a constrained function but outside of an unsafe block.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions