Firma BLS
El ejemplo de firma BLS ilustra cómo implementar la verificación de firmas BLS dentro de un contrato de cuenta personalizada.
Este ejemplo se basa en el ejemplo de cuenta. Aunque el objetivo principal es ilustrar el uso práctico de las funcionalidades de BLS12-381 en un contexto relevante.
Es bueno tener un entendimiento de cómo funciona un contrato de cuenta personalizado, pero no es requisito.
Contexto sobre la firma BLS
Hay muchos buenos recursos sobre la firma BLS, por ejemplo, el "BLS12-381 Para el resto de nosotros" tiene una sección sobre firma digital BLS. El BLS Signature para Personas Ocupadas también es un buen recurso para una visión rápida. Para referencia completa, consulta el borrador de IETF.
En resumen, estamos verificando la siguiente relación:
Donde es la clave pública, es el hash del mensaje en el grupo G2
, es el punto generador en el grupo G1
y es la firma. denota el emparejamiento bilineal entre un punto en G1
y un punto en G2
.
Lo bueno de la firma basada en emparejamiento es que permite la agregación de firmas. Es decir. si tienes múltiples firmantes pk_0 .. pk_n
sobre un solo mensaje, puedes calcular la clave pública agregada sumando todas las claves públicas (recuerda que cada clave pública es solo un punto en el grupo G1), la firma agregada sumando las firmas individuales (que es solo un punto en el grupo G2).
Luego la verificación de la firma agregada es solo
Con un solo emparejamiento en cadena, puedes verificar N firmas en el mismo mensaje en tiempo constante. En general, n
mensajes únicos requieren n + 1
operaciones de emparejamiento para verificar todas las firmas.
Hash del mensaje H(m)
El mensaje deberá ser hasheado en la curva H(m)
para que se aplique la operación de emparejamiento. Seguimos el enfoque descrito en RFC 9380 - Hashing to Elliptic Curves.
El método de hash requiere una etiqueta de separación de dominio única (DST), es altamente recomendable que tu aplicación elija un DST único. Para los requisitos del DST, consulta la sección 3,1 del RFC,
Para firmas digitales, los grupos G1 y G2 pueden usarse de manera intercambiable. Las claves públicas pueden elegirse como elementos de G1 con firmas en G2, o viceversa. La elección implica compensaciones entre velocidad de ejecución y tamaño de almacenamiento. G1 ofrece puntos más pequeños y operaciones más rápidas, mientras que G2 tiene puntos más grandes y un rendimiento más lento.
El ejemplo presentado a continuación está destinado solo para fines de demostración
- Ha no pasado por auditoría de seguridad.
- No es seguro para su uso en entornos de producción.
Implementar un esquema de firma seguro para producción requiere un profundo entendimiento de la criptografía subyacente y consideraciones de seguridad. Usa esto bajo tu propio riesgo.
Ejecutar el ejemplo
Primero pasa por el proceso de Configuración para configurar tu entorno de desarrollo, luego clona la etiqueta v22.0.1
del repositorio soroban-examples
:
git clone -b v22.0.1 https://github.com/stellar/soroban-examples
O, omite la configuración del entorno de desarrollo y abre este ejemplo en Gitpod.
Para ejecutar las pruebas del ejemplo, navega al directorio bls_signature
, y usa cargo test
.
cd bls_signature
cargo test
Deberías ver la salida:
running 1 test
test test::test ... ok
Código
#[contract]
pub struct IncrementContract;
// `DST `is the domain separation tag, intended to keep hashing inputs of your
// contract separate. Refer to section 3.1 in the [Hashing to Elliptic
// Curves](https://datatracker.ietf.org/doc/html/rfc9380) on requirements of
// DST.
const DST: &str = "BLSSIG-V01-CS01-with-BLS12381G2_XMD:SHA-256_SSWU_RO_";
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Owners,
Counter,
Dst,
}
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum AccError {
InvalidSignature = 1,
}
#[contractimpl]
impl IncrementContract {
pub fn init(env: Env, agg_pk: BytesN<96>) {
// Initialize the account contract essentials: the aggregated pubkey and
// the DST. Because the message to be signed (which is
// the hash of some call stack) is the same for all signers, we can
// simply aggregate all signers (adding up the G1 pubkeys) and store it.
env.storage().persistent().set(&DataKey::Owners, &agg_pk);
env.storage()
.instance()
.set(&DataKey::Dst, &Bytes::from_slice(&env, DST.as_bytes()));
// initialize the counter, i.e. the business logic this signer contract
// guards
env.storage().instance().set(&DataKey::Counter, &0_u32);
}
pub fn increment(env: Env) -> u32 {
env.current_contract_address().require_auth();
let mut count: u32 = env.storage().instance().get(&DataKey::Counter).unwrap_or(0);
count += 1;
env.storage().instance().set(&DataKey::Counter, &count);
count
}
}
#[contractimpl]
impl CustomAccountInterface for IncrementContract {
type Signature = BytesN<192>;
type Error = AccError;
#[allow(non_snake_case)]
fn __check_auth(
env: Env,
signature_payload: Hash<32>,
agg_sig: Self::Signature,
_auth_contexts: Vec<Context>,
) -> Result<(), AccError> {
// The sdk module containing access to the bls12_381 functions
let bls = env.crypto().bls12_381();
// Retrieve the aggregated pubkey and the DST from storage
let agg_pk: BytesN<96> = env.storage().persistent().get(&DataKey::Owners).unwrap();
let dst: Bytes = env.storage().instance().get(&DataKey::Dst).unwrap();
// This is the negative of g1 (generator point of the G1 group)
let neg_g1 = G1Affine::from_bytes(bytesn!(&env, 0x17f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb114d1d6855d545a8aa7d76c8cf2e21f267816aef1db507c96655b9d5caac42364e6f38ba0ecb751bad54dcd6b939c2ca));
// Hash the signature_payload i.e. the msg being signed and to be
// verified into a point in G2
let msg_g2 = bls.hash_to_g2(&signature_payload.into(), &dst);
// Prepare inputs to the pairing function
let vp1 = vec![&env, G1Affine::from_bytes(agg_pk), neg_g1];
let vp2 = vec![&env, msg_g2, G2Affine::from_bytes(agg_sig)];
// Perform the pairing check, i.e. e(pk, msg)*e(-g1, sig) == 1, which is
// equivalent to checking `e(pk, msg) == e(g1, sig)`.
// The LHS = e(sk * g1, msg) = sk * e(g1, msg) = e(g1, sk * msg) = e(g1, sig),
// thus it must equal to the RHS if the signature matches.
if !bls.pairing_check(vp1, vp2) {
return Err(AccError::InvalidSignature);
}
Ok(())
}
}
Ref: https://github.com/stellar/soroban-examples/tree/v22.0.1/bls_signature
Cómo funciona
El contrato de ejemplo almacena un contador que solo puede ser incrementado si un conjunto de propietarios lo ha aprobado.
Abre el archivo bls_signature/src/lib.rs
o consulta el código anterior para seguirlo.
El Contrato
#[contract]
pub struct IncrementContract;
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Owners,
Counter,
Dst,
}
#[contractimpl]
impl IncrementContract {
pub fn init(env: Env, agg_pk: BytesN<96>) {
// Initialize the account contract essentials: the aggregated pubkey and
// the DST. Because the message to be signed (which is
// the hash of some call stack) is the same for all signers, we can
// simply aggregate all signers (adding up the G1 pubkeys) and store it.
env.storage().persistent().set(&DataKey::Owners, &agg_pk);
env.storage()
.instance()
.set(&DataKey::Dst, &Bytes::from_slice(&env, DST.as_bytes()));
// initialize the counter, i.e. the business logic this signer contract
// guards
env.storage().instance().set(&DataKey::Counter, &0_u32);
}
pub fn increment(env: Env) -> u32 {
env.current_contract_address().require_auth();
let mut count: u32 = env.storage().instance().get(&DataKey::Counter).unwrap_or(0);
count += 1;
env.storage().instance().set(&DataKey::Counter, &count);
count
}
}
Este contrato es bastante simple y estándar. En init()
, inicializa la clave pública agregada de todos los propietarios, la etiqueta de separación de dominio DST
, e inicializa el contador en 0.
Contiene una única función incrementar
que llama a require_auth
que verificará la condición de autorización definida más adelante, y si es exitosa, incrementa y retorna el contador.
Verificación de firma BLS
#[contractimpl]
impl CustomAccountInterface for IncrementContract {
type Signature = BytesN<192>;
type Error = AccError;
#[allow(non_snake_case)]
fn __check_auth(
env: Env,
signature_payload: Hash<32>,
agg_sig: Self::Signature,
_auth_contexts: Vec<Context>,
) -> Result<(), AccError> {
// The sdk module containing access to the bls12_381 functions
let bls = env.crypto().bls12_381();
// Retrieve the aggregated pubkey and the DST from storage
let agg_pk: BytesN<96> = env.storage().persistent().get(&DataKey::Owners).unwrap();
let dst: Bytes = env.storage().instance().get(&DataKey::Dst).unwrap();
// This is the negative of g1 (generator point of the G1 group)
let neg_g1 = G1Affine::from_bytes(bytesn!(&env, 0x17f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb114d1d6855d545a8aa7d76c8cf2e21f267816aef1db507c96655b9d5caac42364e6f38ba0ecb751bad54dcd6b939c2ca));
// Hash the signature_payload i.e. the msg being signed and to be
// verified into a point in G2
let msg_g2 = bls.hash_to_g2(&signature_payload.into(), &dst);
// Prepare inputs to the pairing function
let vp1 = vec![&env, G1Affine::from_bytes(agg_pk), neg_g1];
let vp2 = vec![&env, msg_g2, G2Affine::from_bytes(agg_sig)];
// Perform the pairing check, i.e. e(pk, msg)*e(-g1, sig) == 1, which is
// equivalent to checking `e(pk, msg) == e(g1, sig)`.
// The LHS = e(sk * g1, msg) = sk * e(g1, msg) = e(g1, sk * msg) = e(g1, sig),
// thus it must equal to the RHS if the signature matches.
if !bls.pairing_check(vp1, vp2) {
return Err(AccError::InvalidSignature);
}
Ok(())
}
}
La función CustomAccountInterface::__check_auth
implementa la lógica de verificación de firma personalizada para esta cuenta.
env.crypto().bls12_381()
inicializa el módulo bls12_381 desde el cual están disponibles las funciones BLS12-381. El payload_firma
contiene el payload que fue firmado.
bls.hash_to_g2(&payload_firma.into(), &dst)
hashea el mensaje en el grupo G2. agg_sig
contiene la firma agregada que es otro punto en G2.
Para realizar la verificación de firma, construimos dos vectores Vec<G1Affine>
y Vec<G2Affine>
, y llamamos bls.pairing_check
sobre ellos. La función de verificación de emparejamiento realiza e(pk, msg)*e(-g1, sig) == 1
que es equivalente a verificar e(pk, msg) == e(g1, sig)
.
Pruebas
Abre el archivo bls_signature/src/test.rs
para seguirlo.
#[test]
fn test() {
let env = Env::default();
let pk = aggregate_pk_bytes(&env);
env.mock_all_auths();
let client = create_client(&env);
client.init(&pk);
let payload = BytesN::random(&env);
let sig_val = sign_and_aggregate(&env, &payload.clone().into()).to_val();
env.try_invoke_contract_check_auth::<AccError>(&client.address, &payload, sig_val, &vec![&env])
.unwrap();
env.cost_estimate().budget().print();
}
La mayor parte de lo que hay aquí es necesario para crear el cliente del contrato y asegurar la invocación de su interfaz de cuenta personalizada para la autorización de la firma. Después de la configuración, llamar a env.try_invoke_contract_check_auth
en el cliente invocará la lógica de __check_auth
que hemos definido en nuestro contrato.
La invocación no retornará nada en caso de éxito, y causará pánico en caso de fallo. env.budget().print()
al final imprime el presupuesto.
Agregación de firma
Primero declaramos 10 pares de claves aleatorias. Estos serán usados como los firmantes de este contrato de prueba.
#[derive(Debug)]
pub struct KeyPair {
pub sk: [u8; 32],
pub pk: [u8; 96],
}
static KEY_PAIRS: &[KeyPair] = &[
KeyPair {
sk: hex!("18a5ac3cfa6d0b10437a92c96f553311fc0e25500d691ae4b26581e6f925ec83"),
pk: hex!("0914e32703bad05ccf4180e240e44e867b26580f36e09331997b2e9effe1f509b1a804fc7ba1f1334c8d41f060dd72550901c5549caef45212a236e288a785d762a087092c769bfa79611b96d73521ddd086b7e05b5c7e4210f50c2ee832e183"),
},
KeyPair {
sk: hex!("738dbecafa122ee3c953f07e78461a4281cadec00c869098bac48c8c57b63374"),
pk: hex!("05f4708a013699229f67d0e16f7c2af8a6557d6d11b737286cfb9429e092c31c412f623d61c7de259c33701aa5387b5004e2c03e8b7ea2740b10a5b4fd050eecca45ccf5588d024cbb7adc963006c29d45a38cb7a06ce2ac45fce52fc0d36572"),
},
KeyPair {
sk: hex!("4bff25b53f29c8af15cf9b8e69988c3ff79c80811d5027c80920f92fad8d137d"),
pk: hex!("18d0fef68a72e0746f8481fa72b78f945bf75c3a1e036fbbde62a421d8f9568a2ded235a27ad3eb0dc234b298b54dd540f61577bc4c6e8842f8aa953af57a6783924c479e78b0d4959038d3d108b3f6dc6a1b02ec605cb6d789af16cfe67f689"),
},
KeyPair {
sk: hex!("2110f7dae25c4300e1a9681bad6311a547269dba69e94efd342cc208ff50813b"),
pk: hex!("1643b04cc21f8af9492509c51a6e20e67fa7923f4fbd52f6fcf73c6a4013f864e3e29eb03f54d234582250ebb5df21140381d0c735e868adfe62f85cf8e85d279864333dbe70656a5f35ebc52c5b497f1c65c7a0144bb0c9a1d843f1a8fb9979"),
},
KeyPair {
sk: hex!("1e4b6d54ac58d317cbe6fb0472c3cbf2e60ea157edea21354cbc198770f81448"),
pk: hex!("02286d1a83a93f35c3461dd71d0840e83e1cd3275ee1af1bfd90ec2366485e9f7f18730f5b686f4810480f1ce5c63dca13a2fac1774aa4e22c29abb9280796d72a2bd0ef963dc76fd45090012bae4a727a6dce49550d9bc9776705f825e24731"),
},
KeyPair {
sk: hex!("471145761f5cd9d0a9a511f1a80657edfcddc43424e4a5582040ea75c4649909"),
pk: hex!("0b7920a3f2a50cfd6dc132a46b7163d3f7d6b1d03d9fcf450eb05dfa89991a269e707e3412270dc422b664d7adda782c11c973232e975ef0d4b4fb5626b563df542fd1862f80bce17cd09bcbce8884bdda4ac9286bf94854dd29cd511a9103a7"),
},
KeyPair {
sk: hex!("1914beab355b0a86a7bcd37f2e036a9c2c6bff7f16d8bf3e23e42b7131b44701"),
pk: hex!("1872237fb7ceccc1a6e85f83988c226cc47db75496e41cf20e8a4b93e8fd5e91d0cdcc3b2946a352223ec2b7817a2aae0dc4e6bb7b97c855828670362fcbd0ad6453f28e4fa4b7a075ac8bb1d69a4a1bb8c6723900fead307239f04a9bcec0ad"),
},
KeyPair {
sk: hex!("46b19b928638068780ba82e76dfeaeaf5c37790cdf37f580e206dc6599c72dc7"),
pk: hex!("0fd1a6b1e46b83a197bbf1dc2a854d024caa5ead5a54893c9767392c837d7c070e86a9206ddba1801332f9d74e0f78e9175419ccc40a966bf4c12a7f8500519e2b83cebd61e32121379911925bf7ae6d2c0d8ec4dcc411d4bbcd14763c1a9d31"),
},
KeyPair {
sk: hex!("0ce3cd1dcaecf002715228aeb0645c6a7fd9990ace3d79515c547dac120bb9f7"),
pk: hex!("19f7e9dcd4ce2bef92180b60d0c7c7b48b1924a36f9fbb93e9ecb8acb3219e26033b83facd4dc6d2e3f9fa0fffafeca8168bd4824e31dc9dfd977fbf037210508bc807c1a6d20f98a044911f6b689328f3f25dd35a6c05e8c6ac3ac6ef0def91"),
},
KeyPair {
sk: hex!("6b4b27ba3ffc953eff3b974142cdac75f98c8c4ab26f93d5adfd49da5d462c3f"),
pk: hex!("15f55ec5572026d6c3c7c62b3ce3c5d7539045e9f492f2b1b0860c0af5f5f6b34531dfe4626a92d5c23ac6ad44330cf40e63a8a7234edbb41539c5484eff2cd23b2f0d502a7fd74501b1a05ffee29b24e79cb1ee9fb9b804d84f486283101ee0"),
},
];
Agregamos las claves públicas de los firmantes, primero convirtiendo los bytes en G1Affine
, y luego sumándolos todos.
fn aggregate_pk_bytes(env: &Env) -> BytesN<96> {
let bls = env.crypto().bls12_381();
let mut agg_pk = G1Affine::from_bytes(BytesN::from_array(env, &KEY_PAIRS[0].pk));
for i in 1..KEY_PAIRS.len() {
let pk = G1Affine::from_bytes(BytesN::from_array(env, &KEY_PAIRS[i].pk));
agg_pk = bls.g1_add(&agg_pk, &pk);
}
agg_pk.to_bytes()
}
Para producir la firma, el mensaje será primero hasheado en G2 a través de bls.hash_to_g2
. Aquí usamos nuestro propio DST definido.
Para agregar las firmas, primero producimos firmas individuales haciendo que cada firmante firme el mensaje. Esto significa multiplicar la clave secreta por el punto G2 del mensaje. Luego los sumamos todos. Aquí usamos g2_msm
, mediante un arreglo de puntos de mensaje (Vec<G2Affine>
) y un arreglo de claves secretas (Vec<Fr>
) y computa su producto interno que es la firma agregada que queremos.
const DST: &str = "BLSSIG-V01-CS01-with-BLS12381G2_XMD:SHA-256_SSWU_RO_";
fn sign_and_aggregate(env: &Env, msg: &Bytes) -> BytesN<192> {
let bls = env.crypto().bls12_381();
let mut vec_sk: Vec<Fr> = vec![env];
for kp in KEY_PAIRS {
vec_sk.push_back(Fr::from_bytes(BytesN::from_array(env, &kp.sk)));
}
let dst = Bytes::from_slice(env, DST.as_bytes());
let msg_g2 = bls.hash_to_g2(&msg, &dst);
let vec_msg: Vec<G2Affine> = vec![
env,
msg_g2.clone(),
msg_g2.clone(),
msg_g2.clone(),
msg_g2.clone(),
msg_g2.clone(),
msg_g2.clone(),
msg_g2.clone(),
msg_g2.clone(),
msg_g2.clone(),
msg_g2.clone(),
];
bls.g2_msm(vec_msg, vec_sk).to_bytes()
}
Ejecutar esta prueba producirá la siguiente salida de presupuesto (se omitió una parte de la salida por brevedad), el consumo total de CPU para la verificación de firmas es de aproximadamente 31M. Y puedes agregar tantas claves públicas adicionales como desees. En general, pairing_check
es una función que tiene un coste lineal, por lo que cuantas más mensajes únicos necesiten ser firmados, mayor será el coste. Aquí porque todos los firmantes firman el mismo contenido (hash de la pila de llamadas de este contrato), podemos hacerlo en tiempo constante.
---- test::test stdout ----
=================================================================
Cpu limit: 100000000; used: 31143102
Mem limit: 41943040; used: 159903
=================================================================
CostType cpu_insns mem_bytes
WasmInsnExec 0 0
MemAlloc 23516 5000
[... previous output omitted for brevity ...]
VerifyEcdsaSecp256r1Sig 0 0
Bls12381EncodeFp 2644 0
Bls12381DecodeFp 11820 0
Bls12381G1CheckPointOnCurve 3868 0
Bls12381G1CheckPointInSubgroup 1461020 0
Bls12381G2CheckPointOnCurve 11842 0
Bls12381G2CheckPointInSubgroup 2115644 0
Bls12381G1ProjectiveToAffine 0 0
Bls12381G2ProjectiveToAffine 0 0
Bls12381G1Add 0 0
Bls12381G1Mul 0 0
Bls12381G1Msm 0 0
Bls12381MapFpToG1 0 0
Bls12381HashToG1 0 0
Bls12381G2Add 0 0
Bls12381G2Mul 0 0
Bls12381G2Msm 0 0
Bls12381MapFp2ToG2 0 0
Bls12381HashToG2 7052263 6816
Bls12381Pairing 20447400 148148
Bls12381FrFromU256 0 0
Bls12381FrToU256 0 0
Bls12381FrAddSub 0 0
Bls12381FrMul 0 0
Bls12381FrPow 0 0
Bls12381FrInv 0 0
=================================================================
Crear el contrato
Para crear el contrato en un archivo .wasm
, usa el comando stellar contract build
.
stellar contract build
El archivo .wasm
debería encontrarse en el directorio target
después de la creación:
target/wasm32-unknown-unknown/release/soroban_bls_signature_contract.wasm