paint-brush
키 관리 시스템: 알아야 할 모든 것~에 의해@vivalaakam
270 판독값

키 관리 시스템: 알아야 할 모든 것

~에 의해 Andrey Makarov25m2024/07/16
Read on Terminal Reader

너무 오래; 읽다

서비스는 256비트 개인 키(대부분의 블록체인 네트워크에서 사용됨)를 저장합니다. EVM 네트워크에 대한 트랜잭션 및 메시지에 서명합니다(다른 네트워크에 대한 지원은 나중에 이루어질 수 있음). 사용자의 개인 키는 우리 서비스를 벗어나서는 안 됩니다. 우리가 공유하는 키는 고유해야 합니다. 각 키에 대한 활동 로그가 있어야 합니다.
featured image - 키 관리 시스템: 알아야 할 모든 것
Andrey Makarov HackerNoon profile picture

얼마 전, 우리는 머글 일을 하면서 다양한 서비스의 개인 키 저장에 관해 질문을 받았습니다. 그 과정에서 우리는 여러 가지 이유로 별로 적합하지 않은 몇 가지 솔루션을 발견했습니다. 나는 언젠가 다시 돌아오기로 결정했다.

서비스 요구 사항

저는 이 서비스가 작동하기 위한 몇 가지 요구 사항을 강조했습니다.

  • 현재 서비스는 256비트 개인 키(대부분의 블록체인 네트워크에서 사용됨)를 저장해야 합니다.
  • EVM 네트워크에 대한 트랜잭션 및 메시지에 서명해야 합니다(다른 네트워크에 대한 지원은 나중에 이루어질 수 있음).
  • 사용자는 무제한의 키를 가질 수 있습니다.
  • 사용자의 개인 키는 당사 서비스를 벗어나서는 안 됩니다.
  • 각 키는 무제한의 사람들과 공유될 수 있습니다.
  • 우리가 공유하는 키는 고유해야 합니다.
  • 각 키에 대한 활동 로그가 있어야 합니다.
  • sk 사용하는 서비스의 경우 키가 손상되면 이를 취소할 수 있어야 합니다.


이미 작업 과정에서 이 작업을 어떻게 해결해야 하는지 깨달았을 때 한 가지 요구 사항을 더 강조했습니다.

  • 각 키의 범위를 제한할 수 있어야 합니다. 하지만 이미 작업이 진행 중이었기 때문에 다음 글로 남겨두었습니다. 따라서 이 기사에서는 메시지에 서명하는 것으로 제한하겠습니다.

문제 해결을 위한 옵션

글에서는 키가 여러번 사용될 예정이므로 혼동을 피하기 위해 개인키를 pk 라고 부르고, 공유되는 키를 sk 부르겠습니다.


초기 옵션은 pbkdf2 함수에서 키를 암호화하는 것이었지만, 이 알고리즘 과정에서 키가 하나만 있기 때문에 서명에 접근하기 위해 키를 공유하는 방법에 문제가 있었습니다.


문제를 해결하기 위한 두 가지 옵션을 찾았습니다.

  1. 우리는 데이터베이스에 암호화된 키의 마스터 키를 보유하고 있으며 생성된 키를 이미 공유하여 원본 키로 연결됩니다. 데이터베이스에 액세스할 수 있으면 pk 키를 쉽게 해독할 수 있기 때문에 이 옵션이 마음에 든다고는 말할 수 없습니다.


  2. 우리는 확인하는 각 키에 대해 별도의 pk 키 인스턴스를 만듭니다. 나도 이 옵션을 별로 좋아하지 않는다고 말하고 싶지는 않다.


그래서 여기저기 돌아다니면서 sk 키를 편리하게 만드는 방법을 고민하던 중, 샤미르 비밀 공유(SSS)를 사용할 때 sk 키를 고유하게 만들어서 키의 일부만 공유할 수 있다는 사실이 생각났습니다. 나머지는 보안을 위해 백엔드에 저장되며, 원하는 사람에게 이 부분을 제공할 수 있습니다.


이는 다음과 같습니다: SSS 생성 키로 pk 키를 암호화하고, 키의 일부를 다른 저장소에 저장하고, 키의 일부를 사용자에게 sk 로 제공합니다. 약 10~15분 후에 저는 한 가지 간단한 사실을 깨달았습니다.


SSS를 사용할 때, SSS가 이를 약간 처리할 수 있기 때문에 다른 어떤 것으로도 pk 키를 암호화할 필요가 없으며, 제 생각에는 이 솔루션이 PK 키를 저장하는 데 완벽합니다. 사용자를 포함하여 다양한 저장 옵션을 사용하여 항상 부품으로 분해됩니다. 취소해야 하는 경우 sk 키의 인덱스 정보를 삭제하고 신속하게 새 키를 조립합니다.


이 기사에서는 SSS의 원칙에 대해 자세히 설명하지 않겠습니다. 나는 이미 이 주제에 관해 짧은 기사를 썼으며 이 기사의 많은 원칙이 우리의 새로운 서비스의 기초가 될 것입니다.

건축학

당사의 서비스 원칙은 다음과 같습니다.

  1. 사용자는 키 생성을 선택합니다.


  2. 서비스에 적합한 키를 생성합니다. 이것이 우리의 pk 키가 될 것입니다. 서비스 전체를 떠나지 않습니다.


  3. SSS를 사용하여 키를 분할하여 pk 키를 복구하려면 분할 키의 세 부분이 필요합니다. 각 분할 키는 두 부분으로 구성됩니다. x: 키의 위치 y: 이 위치의 값


  4. 우리는 첫 번째 부분을 Vault에 넣습니다(API를 통해 액세스할 수 있는 민감한 정보를 저장하는 모든 서비스일 수 있음).


  5. 두 번째 부분은 데이터베이스에 저장합니다(PostgreSQL을 사용하겠습니다).


  6. 세 번째 부분은 부분적으로 데이터베이스에 저장하고 다른 부분은 사용자에게 제공합니다( sk ). SK를 사용하여 필요한 값을 찾기 위해 keccak256(sk) 도 데이터베이스에 저장합니다. 제가 아는 한, 아직 깨지지 않았습니다.


  7. 사용자가 서명해야 할 경우 애플리케이션의 여러 부분에서 개인 키를 수집하여 서명합니다.


이 접근 방식에는 한 가지 단점이 있습니다. sk 키 관리자가 자신이 생성한 모든 sk 키를 잃어버린 경우 원래 키를 다시 복원할 수 없습니다. 옵션으로 원래 키를 백업할 수 있지만 이는 나중에 사용할 수 있습니다 =).

데이터 베이스

작업 결과 다음과 같은 데이터베이스 구조를 갖게 되었습니다.

  • 사용자는 키 관리자라고도 불리는 사용자에 대한 정보를 저장합니다.


  • 키는 공유의 두 번째 부분, Vault에서 공유의 첫 번째 부분을 찾을 수 있는 인덱스, 개인 키 주소와 같은 기타 정보 등 키에 대한 기본 정보를 저장합니다.


  • 공유는 공유의 일부를 포함하며 이 공유의 해시된 값도 저장합니다. 이는 데이터베이스에서 찾을 수 있도록 수행됩니다.


  • 키 생성 및 모든 서명과 같은 키와 관련된 모든 활동이 여기에 기록 됩니다.

실현

나는 이 서비스를 위해 Actix-web 프레임워크와 함께 Rust 프로그래밍 언어를 사용했습니다. 직장에서 항상 사용하는데 왜 안되나요?


앞서 말했듯이 데이터베이스는 다음과 같은 이유로 Postgresql이 될 것입니다.

다항식

 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, } } }

나는 여기서 약간의 고백을 하겠다. 나는 수학자가 아니다. 이에 대해 가능한 한 많은 정보를 찾으려고 노력했지만 사실 이것은 이전 기사에서 가져온 코드입니다.


이 기능에 대한 자세한 내용은 여기 (https://en.wikipedia.org/wiki/Lagrange_polynomial )에서 확인할 수 있습니다.


이 구조(또는 클래스 중 더 편리한 것)는 오늘 설명한 프로세스의 가장 중요한 부분, 즉 pk 키를 여러 조각으로 나누고 다시 조립하는 작업을 수행합니다.

사용자 생성

 #[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)); } } }

여기에서는 모든 것이 가능한 한 간단합니다. 우리는 자신의 키로 작업할 마스터 키를 가진 사용자를 만듭니다. 이는 다른 사람이 우리 키로 어떤 작업도 수행하는 것을 방지하기 위해 수행됩니다. 이상적으로는 이 키가 어떤 방식으로든 배포되어서는 안 됩니다.

키 생성

 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, }) }

해당 사용자가 존재하는지 사용자를 확인하고 pk 키를 생성한 후 여러 부분으로 분할하여 다른 위치에 저장합니다.

액세스 권한 부여

 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, }) }

이 기능의 작동 메커니즘은 다음과 같습니다.


우리는 액세스 요청자가 공유에 대한 모든 권리를 가지고 있는지 확인합니다.


여기에는 매우 간단한 이유로 비밀 키가 필요합니다. 비밀 키가 없으면 원래 pk 키를 복구할 수 없습니다. 추가 공유를 생성하여 사용자에게 제공합니다.

액세스 권한을 취소

 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() }

여기서는 액세스를 취소하려는 공유의 식별자만 알면 됩니다. 나중에 웹 인터페이스를 만들면 작업하기가 더 쉬워질 것입니다. 여기서는 개인 키를 복원하지 않기 때문에 여기서는 sk 키가 필요하지 않습니다.

메시지 서명

 #[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()), }) }

메시지를 받았고 모든 것이 정상이면 개인 키를 복구하고 이를 사용하여 메시지에 서명했습니다.


기본적으로 우리 애플리케이션의 주요 방법이 설명되어 있습니다. 나는 불쌍히 여기고 여기에 전체 코드를 넣지 않기로 결정했습니다. 이에 대한 GitHub가 있으며 거기에서 모든 코드를 사용할 수 있습니다 =)

결론

이는 아직 애플리케이션의 초안이지만 단순한 개념이 아니라는 점에 유의하는 것이 중요합니다. 그것은 가능성을 보여주는 실행 가능한 초안입니다. 작동 방식을 이해하기 위해 저장소에 통합 테스트도 있습니다. 다음 부분에서는 거래에 서명을 추가하고 사용 범위를 제한할 수 있도록 할 예정입니다. 그런 다음 웹 인터페이스를 만들어 이 프로젝트를 일반 사람들에게 친숙하게 만들 수 있습니다.


이 개발은 활용 가능성이 매우 높으며, 적어도 그 일부를 공개할 수 있기를 바랍니다. 이러한 코드 페이지와 부족한 의견에 대해 사과드립니다. 나는 위에서 작성한 내용을 설명하는 것보다 코드를 작성하는 것을 선호합니다. 나는 더 나아지려고 노력할 것이지만 지금은 나보다 더 강합니다.


또한 원하는 경우 코드 및 PR에 대한 의견을 환영합니다 =)


모두의 행운을 빌며, 개인 키를 안전하게 보관하세요.