Signing t-Schnorr messages
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_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.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.
- Motoko
- Rust
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));
};
};
#[update]
async fn sign(message: String, algorithm: SchnorrAlgorithm) -> Result<SignatureReply, String> {
let internal_request = ManagementCanisterSignatureRequest {
message: message.as_bytes().to_vec(),
derivation_path: vec![ic_cdk::api::caller().as_slice().to_vec()],
key_id: SchnorrKeyIds::TestKeyLocalDevelopment.to_key_id(algorithm),
};
let (internal_reply,): (ManagementCanisterSignatureReply,) =
ic_cdk::api::call::call_with_payment(
mgmt_canister_id(),
"sign_with_schnorr",
(internal_request,),
25_000_000_000,
)
.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
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));
}
#[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_bip340_secp256k1(
sig_bytes: &[u8],
msg_bytes: &[u8],
secp1_pk_bytes: &[u8],
) -> Result<SignatureVerificationReply, String> {
assert_eq!(secp1_pk_bytes.len(), 33);
assert_eq!(sig_bytes.len(), 64);
let sig =
k256::schnorr::Signature::try_from(sig_bytes).expect("failed to deserialize signature");
let vk = k256::schnorr::VerifyingKey::from_bytes(&secp1_pk_bytes[1..])
.expect("failed to deserialize BIP340 encoding into public key");
let is_signature_valid = vk.verify_raw(&msg_bytes, &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),
})
}