paint-brush
Sistema de gestión de claves: todo lo que necesita saberby@vivalaakam
231

Sistema de gestión de claves: todo lo que necesita saber

Andrey Makarov25m2024/07/16
Read on Terminal Reader

El servicio almacenará una clave privada de 256 bits (utilizada en la mayoría de las redes blockchain) Firmará transacciones y mensajes para redes EVM (el soporte para otras redes se puede realizar más adelante) La clave privada de un usuario no debe abandonar nuestro servicio. Las claves que compartimos deben ser únicas. Deberíamos tener un registro de actividad con cada clave.
featured image - Sistema de gestión de claves: todo lo que necesita saber
Andrey Makarov HackerNoon profile picture

No hace mucho, en un trabajo muggle, tuvimos una pregunta sobre el almacenamiento de claves privadas para varios servicios. En el proceso encontramos varias soluciones que no eran muy adecuadas por diferentes motivos. Decidí volver a ello en algún momento.

Requisitos para el Servicio

Por mi parte, he resaltado algunos requisitos para que este servicio funcione:

  • Por ahora, el servicio debe almacenar una clave privada de 256 bits (utilizada en la mayoría de las redes blockchain)
  • Debe firmar transacciones y mensajes para redes EVM (el soporte para otras redes se puede realizar más adelante).
  • Un usuario puede tener un número ilimitado de claves.
  • La clave privada de un usuario no debe salir de nuestro servicio.
  • Cada clave se puede compartir con un número ilimitado de personas.
  • Las claves que compartimos deben ser únicas.
  • Deberíamos tener un registro de actividad con cada clave.
  • Si el servicio que utiliza la clave sk está comprometida, deberíamos poder revocarla.


Ya en el proceso de trabajo, cuando me di cuenta de cómo se debía resolver esta tarea, resalté un requisito más:

  • Debemos poder limitar el alcance de cada clave. Sin embargo, como el trabajo ya estaba en marcha, lo dejé para el siguiente artículo. Por tanto, nos limitaremos a firmar el mensaje dentro de este artículo.

Opciones para resolver el problema

Dado que la clave se usará muchas veces en el artículo, llamaré a la clave privada pk para evitar confusiones y a la clave compartida sk .


La opción inicial era cifrar la clave en la función pbkdf2 , pero hubo un problema de cómo compartir la clave para acceder a la firma porque solo tenemos una clave en el proceso de este algoritmo.


Encontré dos opciones para solucionar el problema:

  1. Tenemos una clave maestra de la clave cifrada almacenada en la base de datos y ya hemos compartido la clave generada, que conduce a la clave original. No diría que me gustó esta opción porque si obtienes acceso a la base de datos, la clave pk es fácil de descifrar.


  2. Creamos una instancia separada de nuestra clave pk para cada clave que verificamos. Tampoco diría que me gusta mucho esta opción.


Entonces, mientras caminaba y pensaba en cómo hacer que la clave sk fuera conveniente, recordé que al usar Shamir Secrets Sharing (SSS), puede hacer que la clave sk sea única y compartir solo una parte de la clave. El resto se almacenará en el backend de forma segura y podrás entregárselas a quien quieras.


Se vería así: ciframos la clave pk con nuestra clave generada por SSS, almacenamos parte de la clave en diferentes almacenamientos y le damos parte de la clave al usuario como sk . Después de unos 10 a 15 minutos, me di cuenta de una cosa sencilla:


Cuando usamos SSS, no necesitamos cifrar nuestra clave pk con nada más porque SSS puede manejarla un poco y, en mi opinión, esta solución es perfecta para almacenar claves PK. Siempre se desmonta en piezas utilizando diferentes opciones de almacenamiento, incluida la del usuario. Si es necesario revocarlo, eliminamos la información de índice de nuestra clave sk y rápidamente creamos una nueva.


En este artículo, no me detendré en los principios de SSS; Ya escribí un breve artículo sobre este tema y muchos de los principios de este artículo formarán la base de nuestro nuevo servicio.

Arquitectura

El principio de nuestro servicio será el siguiente:

  1. El usuario elige generar una clave.


  2. Creamos una clave adecuada para el servicio. Será nuestra clave pk . Nunca abandona el servicio en su totalidad.


  3. Usando SSS, dividimos nuestra clave de modo que se requieran tres partes de la clave dividida para recuperar la clave pk . Cada clave dividida consta de dos partes: x: la posición de nuestra clave y: el valor de esta posición


  4. Lanzamos la primera parte a Vault (puede ser cualquier servicio para almacenar información confidencial al que se pueda acceder a través de API).


  5. La segunda parte la guardamos en la base de datos (voy a usar PostgreSQL).


  6. La tercera parte la guardamos parcialmente en la base de datos y la otra parte se la damos al usuario ( sk ). Para usar SK para encontrar el valor que necesitamos, también guardamos keccak256(sk) en la base de datos. Hasta donde yo sé, todavía no se ha roto.


  7. Cuando el usuario necesita firmar algo, recopilamos la clave privada de diferentes partes de la aplicación y la firmamos.


Este enfoque tiene una desventaja: si el administrador de claves sk pierde todas las claves sk que generó, no podemos restaurar la clave original. Como opción, puedes hacer una copia de seguridad de la clave original, pero eso será para otro momento =).

Base de datos

Como resultado de mi trabajo, tengo esta estructura de base de datos:

  • Los usuarios almacenan información sobre el usuario, también conocido como administrador de claves.


  • Keys almacena información básica sobre la clave, como la segunda parte de nuestra Acción, el índice mediante el cual puede encontrar la primera parte de la Acción en la Bóveda y otra información como la dirección de nuestra clave privada.


  • share contiene una parte de Share y también almacena el valor hash de esta Share. Esto se hace para que podamos encontrarlo en la base de datos.


  • registra cualquier actividad con la clave, como la creación de claves y todas las firmas, cae aquí.

Realización

Utilicé el lenguaje de programación Rust con el framework Actix-web para este servicio. Los uso todo el tiempo en el trabajo, ¿por qué no?


Como dije, la base de datos será Postgresql por los siguientes motivos.

Polinomio

 lazy_static! { static ref PRIME: BigUint = BigUint::from_str_radix( "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16 ) .expect("N parse error"); } #[derive(Clone, Debug)] pub struct Share { pub x: BigUint, pub y: BigUint, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ShareStore { pub x: String, pub y: String, } impl From<Share> for ShareStore { fn from(share: Share) -> Self { ShareStore { x: hex::encode(share.x.to_bytes_be()), y: hex::encode(share.y.to_bytes_be()), } } } impl From<&Share> for ShareStore { fn from(share: &Share) -> Self { ShareStore { x: hex::encode(share.x.to_bytes_be()), y: hex::encode(share.y.to_bytes_be()), } } } pub struct Polynomial { prime: BigUint, } impl Polynomial { pub(crate) fn new() -> Self { Polynomial { prime: PRIME.clone(), } } // Calculates the modular multiplicative inverse of `a` modulo `m` using Fermat's Little Theorem. fn mod_inverse(&self, a: &BigUint, m: &BigUint) -> BigUint { a.modpow(&(m - 2u32), m) } // Generates a random polynomial of a given degree with the secret as the constant term. fn random_polynomial(&self, degree: usize, secret: &BigUint) -> Vec<BigUint> { let mut coefficients = vec![secret.clone()]; for _ in 0..degree { let index = BigUint::from_bytes_be(generate_random().as_slice()); coefficients.push(index); } coefficients } // Evaluates a polynomial at a given point `x`, using Horner's method for efficient computation under a prime modulus. fn evaluate_polynomial(&self, coefficients: &[BigUint], x: &BigUint) -> BigUint { let mut result = BigUint::zero(); let mut power = BigUint::one(); for coeff in coefficients { result = (&result + (coeff * &power) % &self.prime) % &self.prime; power = (&power * x) % &self.prime; } result } // Generates `num_shares` shares from a secret, using a polynomial of degree `threshold - 1`. pub fn generate_shares( &self, secret: &BigUint, num_shares: usize, threshold: usize, ) -> Vec<Share> { let coefficients = self.random_polynomial(threshold - 1, secret); let mut shares = vec![]; for _x in 1..=num_shares { let x = BigUint::from_bytes_be(generate_random().as_slice()); let y = self.evaluate_polynomial(&coefficients, &x); shares.push(Share { x, y }); } shares } // Reconstructs the secret from a subset of shares using Lagrange interpolation in a finite field. pub fn reconstruct_secret(&self, shares: &Vec<Share>) -> BigUint { let mut secret = BigUint::zero(); for share_i in shares { let mut numerator = BigUint::one(); let mut denominator = BigUint::one(); for share_j in shares { if share_i.x != share_j.x { numerator = (&numerator * &share_j.x) % &self.prime; let diff = if share_j.x > share_i.x { &share_j.x - &share_i.x } else { &self.prime - (&share_i.x - &share_j.x) }; denominator = (&denominator * &diff) % &self.prime; } } let lagrange = (&share_i.y * &numerator * self.mod_inverse(&denominator, &self.prime)) % &self.prime; secret = (&secret + &lagrange) % &self.prime; } secret } // Adds a new share to the existing set of shares using Lagrange interpolation in a finite field. pub fn add_share(&self, shares: &Vec<Share>) -> Share { let new_index = BigUint::from_bytes_be(generate_random().as_slice()); let mut result = BigUint::zero(); for share_i in shares { let mut lambda = BigUint::one(); for share_j in shares { if share_i.x != share_j.x { let numerator = if new_index.clone() >= share_j.x { (new_index.clone() - &share_j.x) % &self.prime } else { (&self.prime - (&share_j.x - new_index.clone()) % &self.prime) % &self.prime }; let denominator = if share_i.x >= share_j.x { (&share_i.x - &share_j.x) % &self.prime } else { (&self.prime - (&share_j.x - &share_i.x) % &self.prime) % &self.prime }; lambda = (&lambda * &numerator * self.mod_inverse(&denominator, &self.prime)) % &self.prime; } } result = (&result + &share_i.y * &lambda) % &self.prime; } Share { x: new_index, y: result, } } }

Aquí haré una pequeña confesión: no soy matemático. Y aunque intenté encontrar tanta información como pude sobre esto, de hecho, este es un código adaptado de mi artículo anterior.


Puede leer más sobre esta función aquí https://en.wikipedia.org/wiki/Lagrange_polynomial


Esta estructura (o clase, lo que sea más conveniente) realiza la parte más importante del proceso que describimos hoy: romper la clave pk en pedazos y volver a ensamblarla.

Crear usuario

 #[derive(Serialize, Deserialize)] pub struct CreateUserResponse { pub secret: String, } pub async fn users_create_handler(app_data: web::Data<AppData>) -> HttpResponse { let code = generate_code(); match create_user( CreateOrUpdateUser { secret: code.clone(), }, app_data.get_db_connection(), ) .await { Ok(_) => HttpResponse::Ok().json(CreateUserResponse { secret: code }), Err(e) => { return HttpResponse::InternalServerError().body(format!("Error creating user: {}", e)); } } }

Aquí todo es lo más sencillo posible; Creamos un usuario que tiene una clave maestra para trabajar con sus claves. Esto se hace para evitar que cualquier otra parte haga algo con nuestras claves. Idealmente, esta clave no debería distribuirse de ninguna manera.

Generar clave

 pub async fn keys_generate_handler(req: HttpRequest, app_data: web::Data<AppData>) -> HttpResponse { // Check if the request has a master key header let Some(Ok(master_key)) = req.headers().get(MASTER_KEY).map(|header| header.to_str()) else { return HttpResponse::Unauthorized().finish(); }; // Check if user with master key exist let user = match get_user_by_secret(&master_key, app_data.get_db_connection()).await { Ok(user) => user, Err(UserErrors::NotFound(_)) => { return HttpResponse::Unauthorized().finish(); } Err(e) => { return HttpResponse::InternalServerError().body(format!("Error getting user: {}", e)); } }; // generate random `pk` private key let private_key = generate_random(); let Ok(signer) = PrivateKeySigner::from_slice(private_key.as_slice()) else { return HttpResponse::InternalServerError().finish(); }; let secret = BigUint::from_bytes_be(private_key.as_slice()); let poly = Polynomial::new(); // divide `pk` key into 3 shares let shares = poly .generate_shares(&secret, 3, 3) .iter() .map(Into::into) .collect::<Vec<ShareStore>>(); // store first part at Vault let path = generate_code(); if let Err(err) = kv2::set( app_data.get_vault_client().as_ref(), "secret", &path, &shares[0], ) .await { return HttpResponse::InternalServerError().body(format!("Error setting secret: {}", err)); } // Store second part at database and path to first share let key = CreateOrUpdateKey { user_id: user.id, local_key: shares[1].y.clone(), local_index: shares[1].y.clone(), cloud_key: path, address: signer.address(), }; let key = match create_key(key, app_data.get_db_connection()).await { Ok(key) => key, Err(err) => { return HttpResponse::InternalServerError() .body(format!("Error creating key: {}", err)); } }; // Store third part at database as share let share = match create_share( CreateOrUpdateShare { secret: shares[2].y.clone(), key_id: key.id, user_index: shares[2].x.clone(), owner: SharesOwner::Admin, }, app_data.get_db_connection(), ) .await { Ok(share) => share, Err(err) => { return HttpResponse::InternalServerError() .body(format!("Error creating share: {}", err)); } }; let Ok(user_key) = hex::decode(&shares[2].y) else { return HttpResponse::InternalServerError().finish(); }; // Store log let _ = create_log( CreateLog { key_id: key.id, action: "generate_key".to_string(), data: serde_json::json!({ "user_id": user.id }), message: None, }, app_data.get_db_connection(), ) .await; // Return the key and share identifier HttpResponse::Ok().json(KeysGenerateResponse { key: STANDARD.encode(user_key), id: share.id, }) }

Verifique que el usuario exista, cree una clave pk , divídala en partes y guárdelas en diferentes lugares.

Autorizará el acceso

 pub async fn keys_grant_handler(req: HttpRequest, app_data: web::Data<AppData>) -> HttpResponse { // Check if the request has a master key header let Some(Ok(master_key)) = req.headers().get(MASTER_KEY).map(|header| header.to_str()) else { return HttpResponse::Unauthorized().finish(); }; // Check if a user with the master key exists let user = match get_user_by_secret(&master_key, app_data.get_db_connection()).await { Ok(user) => user, Err(UserErrors::NotFound(_)) => { return HttpResponse::Unauthorized().finish(); } Err(e) => { return HttpResponse::InternalServerError().body(format!("Error getting user: {}", e)); } }; // Check if the request has a secret key header let Some(Ok(secret_key)) = req.headers().get(SECRET_KEY).map(|header| header.to_str()) else { return HttpResponse::Unauthorized().finish(); }; let Ok(share) = STANDARD.decode(secret_key) else { return HttpResponse::Unauthorized().finish(); }; // Check if the share exists let share_value = hex::encode(share); let share = match get_share_by_secret(&share_value, app_data.get_db_connection()).await { Ok(share) => share, Err(ShareErrors::NotFound(_)) => return HttpResponse::NotFound().finish(), Err(_) => { return HttpResponse::Unauthorized().finish(); } }; if !matches!(share.status, SharesStatus::Granted) { return HttpResponse::Unauthorized().finish(); } // Get original key with necessary information let key = match get_key_by_id(&share.key_id, app_data.get_db_connection()).await { Ok(key) => key, Err(KeyErrors::NotFound(_)) => return HttpResponse::NotFound().finish(), Err(_) => { return HttpResponse::Unauthorized().finish(); } }; // Check if the key belongs to the user if key.user_id != user.id { return HttpResponse::Unauthorized().finish(); } // Get the first part of the key from Vault let Ok(cloud_secret) = kv2::read::<ShareStore>( app_data.get_vault_client().as_ref(), "secret", &key.cloud_key, ) .await else { return HttpResponse::InternalServerError().finish(); }; // Combine the shares let shares = vec![ Share { x: BigUint::from_str_radix(&cloud_secret.x, 16).expect("Error parsing local index"), y: BigUint::from_str_radix(&cloud_secret.y, 16).expect("Error parsing local key"), }, Share { x: BigUint::from_str_radix(&key.local_index, 16).expect("Error parsing local index"), y: BigUint::from_str_radix(&key.local_key, 16).expect("Error parsing local key"), }, Share { x: BigUint::from_str_radix(&share.user_index, 16).expect("Error parsing user index"), y: BigUint::from_str_radix(&share_value, 16).expect("Error parsing user key"), }, ]; let sss = Polynomial::new(); // Create a new share let new_share = ShareStore::from(sss.add_share(&shares)); // Store new share into database let share = match create_share( CreateOrUpdateShare { secret: new_share.y.to_string(), key_id: key.id, user_index: new_share.x.to_string(), owner: SharesOwner::Guest, }, app_data.get_db_connection(), ) .await { Ok(share) => share, Err(err) => { return HttpResponse::InternalServerError() .body(format!("Error creating share: {}", err)); } }; let Ok(user_key) = hex::decode(&new_share.y).map(|k| STANDARD.encode(k)) else { return HttpResponse::InternalServerError().finish(); }; // Store log let _ = create_log( CreateLog { key_id: key.id, action: "grant".to_string(), data: serde_json::json!({ "user_id": user.id, "share_id": share.id, }), message: None, }, app_data.get_db_connection(), ) .await; // Return the key and share the identifier HttpResponse::Ok().json(KeysGenerateResponse { key: user_key, id: share.id, }) }

El mecanismo de funcionamiento de esta función es el siguiente:


Verificamos que el solicitante de acceso tiene todos los derechos sobre la Acción.


Necesitamos la clave secreta aquí por una razón muy simple: sin ella no podemos recuperar la clave pk original. Cree un recurso compartido adicional y entrégueselo al usuario.

Revocar el acceso

 pub async fn keys_revoke_handler( req: HttpRequest, app_data: web::Data<AppData>, body: web::Json<KeysRevokeRequest>, ) -> HttpResponse { let Some(Ok(master_key)) = req.headers().get(MASTER_KEY).map(|header| header.to_str()) else { return HttpResponse::Unauthorized().finish(); }; let user = match get_user_by_secret(&master_key, app_data.get_db_connection()).await { Ok(user) => user, Err(UserErrors::NotFound(_)) => { return HttpResponse::Unauthorized().finish(); } Err(e) => { return HttpResponse::InternalServerError().body(format!("Error getting user: {}", e)); } }; let share = match get_share_by_id(&body.id, app_data.get_db_connection()).await { Ok(share) => share, Err(ShareErrors::NotFound(_)) => return HttpResponse::NotFound().finish(), Err(_) => { return HttpResponse::Unauthorized().finish(); } }; let key = match get_key_by_id(&share.key_id, app_data.get_db_connection()).await { Ok(key) => key, Err(KeyErrors::NotFound(_)) => return HttpResponse::NotFound().finish(), Err(_) => { return HttpResponse::Unauthorized().finish(); } }; if key.user_id != user.id { return HttpResponse::Unauthorized().finish(); } if revoke_share_by_id(&share.id, app_data.get_db_connection()) .await .is_err() { return HttpResponse::InternalServerError().finish(); } let _ = create_log( CreateLog { key_id: key.id, action: "revoke".to_string(), data: serde_json::json!({ "user_id": user.id, "share_id": share.id, }), message: None, }, app_data.get_db_connection(), ) .await; HttpResponse::Ok().finish() }

Aquí sólo necesitamos saber el identificador del Share al que revocamos el acceso. En el futuro, si creo una interfaz web, será más fácil trabajar con ella. No necesitamos nuestra clave sk aquí porque no vamos a restaurar la clave privada aquí.

Mensaje de firma

 #[derive(Deserialize, Serialize, Debug)] pub struct SignMessageRequest { pub message: String, } #[derive(Deserialize, Serialize, Debug)] pub struct SignMessageResponse { pub signature: String, } pub async fn sign_message_handler( app_data: web::Data<AppData>, req: HttpRequest, body: web::Json<SignMessageRequest>, ) -> HttpResponse { // Get the `sk` key from the request headers let Some(Ok(secret_key)) = req.headers().get(SECRET_KEY).map(|header| header.to_str()) else { return HttpResponse::Unauthorized().finish(); }; // restore shares let (shares, key_id, share_id) = match restore_shares(secret_key, &app_data).await { Ok(shares) => shares, Err(e) => { return HttpResponse::BadRequest().json(json!({"error": e.to_string()})); } }; let sss = Polynomial::new(); // restore `pk` key let private_key = sss.reconstruct_secret(&shares); //sign message let Ok(signer) = PrivateKeySigner::from_slice(private_key.to_bytes_be().as_slice()) else { return HttpResponse::InternalServerError().finish(); }; let Ok(signature) = signer.sign_message(body.message.as_bytes()).await else { return HttpResponse::InternalServerError().finish(); }; // create log let _ = create_log( CreateLog { key_id, action: "sign_message".to_string(), data: json!({ "share_id": share_id, }), message: Some(body.message.clone()), }, app_data.get_db_connection(), ) .await; // return signature HttpResponse::Ok().json(SignMessageResponse { signature: hex::encode(signature.as_bytes()), }) }

Recibí el mensaje, si todo estaba bien, recuperé la clave privada y firmé el mensaje con ella.


Básicamente se describen los principales métodos de nuestra aplicación; Decidí tener lástima y no poner el código completo aquí. Existe GitHub para eso y todo el código estará disponible allí =)

Conclusión

Si bien esto es todavía un borrador de la solicitud, es importante señalar que no es sólo un concepto. es un borrador viable que parece prometedor. También hay pruebas de integración en el repositorio para entender cómo funciona. En las siguientes partes, planeo agregar la firma de transacciones y permitir limitar el alcance de su uso. Luego puedo crear una interfaz web y hacer que este proyecto sea amigable para la persona promedio.


Este desarrollo tiene bastante potencial de uso y espero poder revelar al menos parte de él; Pido disculpas por estas páginas de código y escasos comentarios. Prefiero escribir código en lugar de explicar lo que escribí arriba. Intentaré mejorar, pero ahora esto es más fuerte que yo.


Además, bienvenidos los comentarios sobre el código y las relaciones públicas si lo desea =)


Mis mejores deseos para todos y mantengan sus claves privadas a salvo.