Signing with t-Schnorr
The threshold Schnorr API allows canisters to securely sign messages and transactions without the corresponding private keys ever existing. Each canister can control an unlimited number of keys by specifying different key derivation_path
s and key_id
s.
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. Optionally, the caller can provide a 0 or 32-byte taproot merkle tree root as a key tweak as defined in BIP341.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_key
: 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.
Also, optionally you can provide a 0 or 32-byte taproot merkle tree root as a
key tweak for bip340secp256k1
as defined in
BIP341.
- Motoko
- Rust
public shared ({ caller }) func sign(message_arg : Text, algorithm : SchnorrAlgorithm, bip341TweakHex : ?Text) : async {
#ok : { signature_hex : Text };
#err : Text;
} {
let aux = switch (Option.map(bip341TweakHex, tryHexToTweak)) {
case (null) null;
case (?#ok some) ?some;
case (?#err err) return #err err;
};
try {
Cycles.add<system>(25_000_000_000);
let signArgs = {
message = Text.encodeUtf8(message_arg);
derivation_path = [Principal.toBlob(caller)];
key_id = { algorithm; name = "insecure_test_key_1" };
aux;
};
let { signature } = await ic.sign_with_schnorr(signArgs);
#ok({ signature_hex = Hex.encode(Blob.toArray(signature)) });
} catch (err) {
#err(Error.message(err));
};
};
#[update]
async fn sign(
message: String,
algorithm: SchnorrAlgorithm,
opt_merkle_tree_root_hex: Option<String>,
) -> Result<SignatureReply, String> {
let aux = opt_merkle_tree_root_hex
.map(|hex| {
hex::decode(&hex)
.map_err(|e| format!("failed to decode hex: {e:?}"))
.and_then(|bytes| {
if bytes.len() == 32 || bytes.is_empty() {
Ok(SignWithSchnorrAux::Bip341(SignWithBip341Aux {
merkle_root_hash: ByteBuf::from(bytes),
}))
} else {
Err(format!(
"merkle tree root bytes must be 0 or 32 bytes long but got {}",
bytes.len()
))
}
})
})
.transpose()?;
let internal_request = ManagementCanisterSignatureRequest {
message: message.as_bytes().to_vec(),
derivation_path: vec![ic_cdk::api::caller().as_slice().to_vec()],
key_id: SchnorrKeyIds::ChainkeyTestingCanisterKey1.to_key_id(algorithm),
aux,
};
let (internal_reply,): (ManagementCanisterSignatureReply,) =
ic_cdk::api::call::call_with_payment(
mgmt_canister_id(),
"sign_with_schnorr",
(internal_request,),
26_153_846_153,
)
.await
.map_err(|e| format!("sign_with_schnorr failed {e:?}"))?;
Ok(SignatureReply {
signature_hex: hex::encode(&internal_reply.signature),
})
}
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.
- JavaScript
- Rust
// BIP340 verification
async function run() {
try {
const ecc = await import('tiny-secp256k1');
const test_sig = Buffer.from('311e1dceddd1380d0424e01b19711e926ca2f26c0dda57b405bec1359510674871a22487c96afa4a4bf47858d1d79caa400bb51ab793d9fad2a689f8bfc681aa', 'hex');
const test_pubkey = Buffer.from('02472bb4da5c5ce627d599feba90d0257a558d4e226f9fc7914f811e301ad06f38'.substring(2), 'hex');
const test_msg = Uint8Array.from(Buffer.from("hellohellohellohellohellohello12", 'utf8'));
console.log(ecc.verifySchnorr(test_msg, test_pubkey, test_sig));
}
catch(err) {
console.log(err);
}
}
run();
// BIP341 verification
async function run() {
try {
const bip341 = await import('bitcoinjs-lib/src/payments/bip341.js');
const bitcoin = await import('bitcoinjs-lib');
const ecc = await import('tiny-secp256k1');
bitcoin.initEccLib(ecc);
const test_tweak = Buffer.from('012345678901234567890123456789012345678901234567890123456789abcd', 'hex');
const test_sig = Buffer.from('3c3e51fc771a5a8cb553bf2dd151bb02d0f473ff274a92d32310267977918d72121f97c318226422c033d33daf376d42c9a07e71643ff332cb30611fe5e163da', 'hex');
const test_pubkey = Buffer.from('02472bb4da5c5ce627d599feba90d0257a558d4e226f9fc7914f811e301ad06f38'.substring(2), 'hex');
const test_msg = Uint8Array.from(Buffer.from("hellohellohellohellohellohello12", 'utf8'));
const tweaked_test_pubkey = bip341.tweakKey(test_pubkey, test_tweak).x;
console.log(ecc.verifySchnorr(test_msg, tweaked_test_pubkey, test_sig));
}
catch(err) {
console.log(err);
}
}
run();
#[query]
async fn verify(
signature_hex: String,
message: String,
public_key_hex: String,
opt_merkle_tree_root_hex: Option<String>,
algorithm: SchnorrAlgorithm,
) -> Result<SignatureVerificationReply, String> {
let sig_bytes = hex::decode(&signature_hex).expect("failed to hex-decode signature");
let msg_bytes = message.as_bytes();
let pk_bytes = hex::decode(&public_key_hex).expect("failed to hex-decode public key");
match algorithm {
SchnorrAlgorithm::Bip340Secp256k1 => match opt_merkle_tree_root_hex {
Some(merkle_tree_root_hex) => {
let merkle_tree_root_bytes = hex::decode(&merkle_tree_root_hex)
.expect("failed to hex-decode merkle tree root");
verify_bip341_secp256k1(&sig_bytes, msg_bytes, &pk_bytes, &merkle_tree_root_bytes)
}
None => verify_bip340_secp256k1(&sig_bytes, msg_bytes, &pk_bytes),
},
SchnorrAlgorithm::Ed25519 => {
if let Some(_) = opt_merkle_tree_root_hex {
return Err("ed25519 does not support merkle tree root verification".to_string());
}
verify_ed25519(&sig_bytes, &msg_bytes, &pk_bytes)
}
}
}
fn verify_bip340_secp256k1(
sig_bytes: &[u8],
msg_bytes: &[u8],
secp1_pk_bytes: &[u8],
) -> Result<SignatureVerificationReply, String> {
assert_eq!(secp1_pk_bytes.len(), 33);
let sig = bitcoin::secp256k1::schnorr::Signature::from_slice(sig_bytes)
.expect("failed to deserialize signature");
let pk = bitcoin::secp256k1::XOnlyPublicKey::from_slice(&secp1_pk_bytes[1..])
.expect("failed to deserialize BIP340 encoding into public key");
let secp256k1_engine = Secp256k1::new();
let msg =
bitcoin::secp256k1::Message::from_digest_slice(msg_bytes).expect("failed to parse message");
let is_signature_valid = pk.verify(&secp256k1_engine, &msg, &sig).is_ok();
Ok(SignatureVerificationReply { is_signature_valid })
}
fn verify_bip341_secp256k1(
sig_bytes: &[u8],
msg_bytes: &[u8],
secp1_pk_bytes: &[u8],
merkle_tree_root_bytes: &[u8],
) -> Result<SignatureVerificationReply, String> {
assert_eq!(secp1_pk_bytes.len(), 33);
let pk = XOnlyPublicKey::from_slice(&secp1_pk_bytes[1..]).unwrap();
let tweaked_pk_bytes = {
let secp256k1_engine = Secp256k1::new();
let merkle_root = if merkle_tree_root_bytes.len() == 0 {
None
} else {
Some(
bitcoin::hashes::Hash::from_slice(&merkle_tree_root_bytes)
.expect("failed to create TapBranchHash"),
)
};
pk.tap_tweak(&secp256k1_engine, merkle_root)
.0
.to_inner()
.serialize()
};
let sig = bitcoin::secp256k1::schnorr::Signature::from_slice(sig_bytes)
.expect("failed to deserialize signature");
let pk = bitcoin::secp256k1::XOnlyPublicKey::from_slice(&tweaked_pk_bytes)
.expect("failed to deserialize tweaked BIP340 encoding into public key");
let secp256k1_engine = Secp256k1::new();
let msg =
bitcoin::secp256k1::Message::from_digest_slice(msg_bytes).expect("failed to parse message");
let is_signature_valid = pk.verify(&secp256k1_engine, &msg, &sig).is_ok();
Ok(SignatureVerificationReply { is_signature_valid })
}
Ed25519 can be verified using code such as:
- JavaScript
- Rust
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));
}
#[query]
async fn verify(
signature_hex: String,
message: String,
public_key_hex: String,
algorithm: SchnorrAlgorithm,
) -> Result<SignatureVerificationReply, String> {
let sig_bytes = hex::decode(&signature_hex).expect("failed to hex-decode signature");
let msg_bytes = message.as_bytes();
let pk_bytes = hex::decode(&public_key_hex).expect("failed to hex-decode public key");
match algorithm {
SchnorrAlgorithm::Bip340Secp256k1 => {
verify_bip340_secp256k1(&sig_bytes, msg_bytes, &pk_bytes)
}
SchnorrAlgorithm::Ed25519 => verify_ed25519(&sig_bytes, &msg_bytes, &pk_bytes),
}
}
fn verify_ed25519(
sig_bytes: &[u8],
msg_bytes: &[u8],
pk_bytes: &[u8],
) -> Result<SignatureVerificationReply, String> {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let pk: [u8; 32] = pk_bytes
.try_into()
.expect("ed25519 public key incorrect length");
let vk = VerifyingKey::from_bytes(&pk).unwrap();
let signature = Signature::from_slice(sig_bytes).expect("ed25519 signature incorrect length");
let is_signature_valid = vk.verify(msg_bytes, &signature).is_ok();
Ok(SignatureVerificationReply { is_signature_valid })
}
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.
- Motoko
- 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));
};
};
#[update]
async fn public_key(algorithm: SchnorrAlgorithm) -> Result<PublicKeyReply, String> {
let request = ManagementCanisterSchnorrPublicKeyRequest {
canister_id: None,
derivation_path: vec![ic_cdk::api::caller().as_slice().to_vec()],
key_id: SchnorrKeyIds::TestKeyLocalDevelopment.to_key_id(algorithm),
};
let (res,): (ManagementCanisterSchnorrPublicKeyReply,) =
ic_cdk::call(mgmt_canister_id(), "schnorr_public_key", (request,))
.await
.map_err(|e| format!("schnorr_public_key failed {}", e.1))?;
Ok(PublicKeyReply {
public_key_hex: hex::encode(&res.public_key),
})
}