Não faz muito tempo, em um trabalho trouxa, tivemos uma dúvida sobre o armazenamento de chaves privadas para vários serviços. No processo, encontramos diversas soluções que não eram muito adequadas por diversos motivos. Decidi voltar a isso algum dia.
Para mim, destaquei alguns requisitos para que este serviço funcione:
sk
estiver comprometido, poderemos revogá-la.
Já no processo de trabalho, quando percebi como essa tarefa deveria ser resolvida, destaquei mais um requisito:
Como a chave será usada muitas vezes no artigo, chamarei a chave privada pk
para evitar confusão e a chave compartilhada sk
.
A opção inicial era criptografar a chave na função pbkdf2
, porém houve um problema em como compartilhar a chave para acessar a assinatura pois temos apenas uma chave no processo deste algoritmo.
Encontrei duas opções para resolver o problema:
Temos uma chave mestra da chave criptografada armazenada no banco de dados e já compartilhamos a chave gerada, que leva à chave original. Eu não diria que gostei dessa opção porque se você tiver acesso ao banco de dados, a chave pk
será fácil de descriptografar.
Criamos uma instância separada de nossa chave pk
para cada chave que verificamos. Eu também não diria que gosto muito dessa opção.
Então, andando por aí e pensando em como tornar a chave sk conveniente, lembrei que ao usar o Shamir Secrets Sharing (SSS), você pode tornar a chave sk
única e compartilhar apenas uma parte da chave. O restante será armazenado no backend em segurança, e você poderá doar essas peças para quem quiser.
Seria assim: criptografamos a chave pk com nossa chave gerada pelo SSS, armazenamos parte da chave em armazenamentos diferentes e fornecemos parte da chave ao usuário como sk
. Após cerca de 10 a 15 minutos, percebi uma coisa simples:
Ao usar o SSS, não precisamos criptografar nossa chave pk
com mais nada porque o SSS pode lidar com isso um pouco, e esta solução é perfeita para armazenar chaves PK, na minha opinião. É sempre desmontado em partes utilizando diferentes opções de armazenamento, inclusive do usuário. Se precisar ser revogada, excluímos as informações de índice de nossa chave sk
e montamos rapidamente uma nova.
Neste artigo, não me deterei nos princípios do SSS; Já escrevi um pequeno artigo sobre este tópico e muitos princípios deste artigo formarão a base do nosso novo serviço.
O princípio do nosso serviço será o seguinte:
O usuário escolhe gerar uma chave.
Criamos uma chave adequada para o serviço. Será nossa chave pk
. Nunca sai do serviço como um todo.
Usando SSS, dividimos nossa chave de modo que três partes da chave dividida sejam necessárias para recuperar a chave pk
. Cada chave dividida consiste em duas partes: x: a posição da nossa chave y: o valor desta posição
Lançamos a primeira parte no Vault (pode ser qualquer serviço de armazenamento de informações confidenciais que possa ser acessado via API).
A segunda parte salvamos no banco de dados (vou usar PostgreSQL).
A terceira parte salvamos parcialmente no banco de dados e a outra parte damos ao usuário ( sk
). Para usar SK para encontrar o valor que precisamos, também salvamos keccak256(sk)
no banco de dados. Pelo que eu sei, ainda não foi quebrado.
Quando o usuário precisa assinar algo, coletamos a chave privada de diferentes partes do aplicativo e assinamos.
Essa abordagem tem uma desvantagem: se o administrador da chave sk
perder todas as chaves sk
que foram geradas por ele, não poderemos restaurar a chave original. Como opção, você pode fazer um backup da chave original, mas isso fica para outra hora =).
Como resultado do meu trabalho, tenho esta estrutura de banco de dados:
Usei a linguagem de programação Rust com o framework Actix-web para este serviço. Eu os uso o tempo todo no trabalho, então por que não?
Como eu disse, o banco de dados será Postgresql pelos seguintes motivos.
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, } } }
Vou fazer uma pequena confissão aqui: não sou matemático. E embora eu tenha tentado encontrar o máximo de informações possível sobre isso, na verdade, este é um código adaptado do meu artigo anterior.
Você pode ler mais sobre esse recurso aqui https://en.wikipedia.org/wiki/Lagrange_polynomial
Essa estrutura (ou classe, o que for mais conveniente) realiza a parte mais importante do processo que descrevemos hoje – quebrar a chave pk
em pedaços e remontá-la novamente.
#[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)); } } }
Aqui tudo é o mais simples possível; criamos um usuário que possui uma chave mestra para trabalhar com suas chaves. Isso é feito para evitar que qualquer outra parte faça algo com nossas chaves. Idealmente, esta chave não deve ser distribuída de forma alguma.
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 se esse usuário existe, crie uma chave pk
, divida-a em partes e salve-as em locais diferentes.
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, }) }
O mecanismo de funcionamento desta função é o seguinte:
Verificamos se o solicitante do acesso possui todos os direitos sobre o compartilhamento.
Precisamos da chave secreta aqui por um motivo muito simples, sem ela não podemos recuperar a chave pk
original. Crie um compartilhamento adicional e entregue-o ao usuário.
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() }
Aqui, só precisamos de saber o identificador da Partilha à qual estamos a revogar o acesso. No futuro, se eu fizer uma interface web, será mais fácil trabalhar com ela. Não precisamos de nossa chave sk
aqui porque não estamos restaurando a chave privada aqui.
#[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()), }) }
Recebi a mensagem, se estava tudo bem, recuperei a chave privada e assinei a mensagem com ela.
Basicamente, são descritos os principais métodos de nossa aplicação; Resolvi ter pena e não colocar o código inteiro aqui. Existe o GitHub para isso, e todo o código estará disponível lá =)
Embora este ainda seja um rascunho do aplicativo, é importante observar que não é apenas um conceito. é um rascunho viável que se mostra promissor. Também existem testes de integração no repositório para entender como funciona. Nas partes seguintes, pretendo adicionar a assinatura das transações e permitir limitar o escopo de sua utilização. Posso então criar uma interface web e tornar este projeto amigável para a pessoa comum.
Este desenvolvimento tem um grande potencial de utilização e espero poder revelar pelo menos parte dele; Peço desculpas por essas páginas de código e comentários esparsos. Prefiro escrever código em vez de explicar o que escrevi acima. Vou tentar melhorar, mas isso é mais forte agora do que eu.
Além disso, receba comentários sobre o código e PR, se desejar =)
Muitas felicidades a todos e mantenham suas chaves privadas seguras.