Skip to main content

Signing t-Schnorr messages

Advanced
Tutorial

Overview

The threshold Schnorr API allows canisters to securely sign messages and transactions without anyone ever having direct access to the corresponding private keys. Each canister can control an unlimited number of keys by specifying different key derivation_paths and key_ids.

The API provides two methods:

  • sign_with_schnorr: Used to sign messages.

  • schnorr_public_key: Used to obtain public keys.

Signatures will have different encoding depending on the provided key_id algorithm:

  • bip340secp256k1: The signature will be encoded according to BIP340, using 64 bytes.

  • ed25519: The signature will be encoded according to RFC8032, 5.1.6 Sign, using 64 bytes.

Difference between t-ECDSA and t-Schnorr API endpoints

The sign_with_schnorr endpoint takes a full message to sign as input rather than a message hash used by the sign_with_ecdsa API endpoint. This is because BIP340 and Ed25519 signature messages are hashed together with other information, meaning a canister cannot do the message hashing as it is done for sign_with_ecdsa.

Signing messages

Canisters can sign message with threshold Schnorr without holding the Schnorr keys themselves. Each key is derived from a master key held by the dedicated threshold Schnorr subnet. When a canister needs a signature, a request is sent to the threshold Schnorr subnet that computes the signature based on the requesting canister's root key and then returns the signature to the canister.

To sign a message, the sign_with_schnorr method is used, which requires that you specify the Schnorr algorithm to be used, either bip340secp256k1 or ed25519.

To deploy code using the sign_with_schnorr method, you will need to specify the correct key_ID for the environment in the canister's source code. The following key IDs are supported:

  • dfx_test_key1: Only available on the local replica started by dfx.
  • test_key_1: Test key available on the ICP mainnet.
  • key_1: Production key available on the ICP mainnet.
  public shared ({ caller }) func sign(message_arg : Text, algorithm_arg : SchnorrAlgotirhm) : async {
    #Ok : { signature_hex : Text };
    #Err : Text;
  } {
    try {
      Cycles.add(25_000_000_000);
      let { signature } = await ic.sign_with_schnorr({
        message = Text.encodeUtf8(message_arg);
        derivation_path = [Principal.toBlob(caller)];
        key_id = { algorithm = algorithm_arg; name = "dfx_test_key" };
      });
      #Ok({ signature_hex = Hex.encode(Blob.toArray(signature)) });
    } catch (err) {
      #Err(Error.message(err));
    };
  };

Cost

The sign_with_schnorr method requires cycles to be attached.

Learn more about threshold Schnorr costs.

Verifying signatures

Created signatures can be verified with the public key corresponding to the canister that initiated the request and the derivation path. For threshold Schnorr, Ed25519 and BIP340 are verified differently.

Calls to the verify method should always return true if the correct parameters are provided or return false otherwise.

BIP340 can be verified using code such as:

The first byte of the BIP340 public key needs to be removed for verification. Below, this is done by the verification function internally.

import('@noble/curves/secp256k1').then((bip340) => { verify(bip340.schnorr); })
  .catch((err) => { console.log(err) });

function verify(bip340) {
  const test_sig = '1b64ca7a7f02c76633954f320675267685b3b80560eb6a35cda20291ddefc709364e59585771c284e46264bfbb0620e23eb8fb274994f7a6f2fcbc8a9430e5d7';
  // the first byte of the BIP340 public key is truncated
  const pubkey = '0341d7cf39688e10b5f11f168ad0a9e790bcb429d7d486eab07d2c824b85821470'.substring(2)
  const test_msg = Uint8Array.from(Buffer.from("hello", 'utf8'));

  console.log(bip340.verify(test_sig, test_msg, test_pubkey));
}

Ed25519 can be verified using code such as:

import('@noble/curves/ed25519').then((ed25519) => { verify(ed25519.ed25519); })
  .catch((err) => { console.log(err) });

function verify(ed25519) {
  const test_sig = '1efa03b7b7f9077449a0f4b3114513f9c90ccf214166a8907c23d9c2bbbd0e0e6e630f67a93c1bd525b626120e86846909aedf4c58763ae8794bcef57401a301'
  const test_pubkey = '566d53caf990f5f096d151df70b2a75107fac6724cb61a9d6d2aa63e1496b003'
  const test_msg = Uint8Array.from(Buffer.from("hello", 'utf8'));

  console.log(ed25519.verify(test_sig, test_msg, test_pubkey));
  }

Verification can be done in other languages using libraries and packages that support bip340secp256k1 signing with an arbitrary message length.

Obtaining public keys

To obtain public keys, you will need to call the schnorr_public_key method of the IC management canister (aaaaa-aa). You can deploy the threshold Schnorr sample locally and use the Candid UI to call this method, or you can call this method programmatically using Motoko or Rust.

  public shared ({ caller }) func public_key(algorithm_arg : SchnorrAlgotirhm) : async {
    #Ok : { public_key_hex : Text };
    #Err : Text;
  } {
    try {
      let { public_key } = await ic.schnorr_public_key({
        canister_id = null;
        derivation_path = [Principal.toBlob(caller)];
        key_id = { algorithm = algorithm_arg; name = "dfx_test_key" };
      });
      #Ok({ public_key_hex = Hex.encode(Blob.toArray(public_key)) });
    } catch (err) {
      #Err(Error.message(err));
    };
  };

Resources