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 удобным, я вспомнил, что при использовании Shamir Secrets Sharing (SSS) можно сделать ключ sk уникальным и делиться только частью ключа. Остальное будет храниться на сервере в безопасности, и вы сможете передать эти части кому захотите.


Выглядело бы это так: шифруем ключ pk нашим ключом, сгенерированным SSS, часть ключа храним в разных хранилищах, а часть ключа отдаем пользователю как sk . Примерно через 10-15 минут я понял одну простую вещь:


При использовании SSS нам не нужно шифровать наш ключ pk чем-либо еще, потому что SSS немного может с этим справиться, и, на мой взгляд, это решение идеально подходит для хранения ключей PK. Он всегда разбирается на детали с использованием разных вариантов хранения, в том числе и пользовательского. Если его необходимо отозвать, мы удаляем индексную информацию нашего sk ключа и быстро собираем новый.


В этой статье я не буду останавливаться на принципах SSS; Я уже написал небольшую статью на эту тему и многие принципы из этой статьи лягут в основу нашего нового сервиса.

Архитектура

Принцип нашего сервиса будет следующим:

  1. Пользователь выбирает создание ключа.


  2. Создаем подходящий ключ для сервиса. Это будет наш ключ pk . Он никогда не покидает службу в целом.


  3. Используя SSS, мы разделяем наш ключ так, чтобы для восстановления ключа pk требовались три части разделенного ключа. Каждый разделенный ключ состоит из двух частей: x: позиция нашего ключа y: значение этой позиции.


  4. Первую часть кидаем в Vault (это может быть любой сервис для хранения конфиденциальной информации, доступ к которому возможен через API).


  5. Вторую часть сохраняем в базу данных (я собираюсь использовать PostgreSQL).


  6. Третью часть частично сохраняем в базу данных, а другую часть отдаем пользователю ( sk ). Чтобы использовать SK для поиска нужного нам значения, мы также сохраняем keccak256(sk) в базу данных. Насколько я знаю, он еще не сломался.


  7. Когда пользователю нужно что-то подписать, мы собираем закрытый ключ из разных частей приложения и подписываем его.


У этого подхода есть один недостаток: если администратор sk ключа потеряет все свои сгенерированные им sk ключи, мы не сможем восстановить исходный ключ. Как вариант можно сделать бэкап оригинального ключа, но это в другой раз =).

База данных

В результате моей работы у меня есть такая структура базы данных:

  • пользователи хранят информацию о пользователе, известном как ключевой администратор.


  • В ключах хранится основная информация о ключе, такая как вторая часть нашего общего ресурса, индекс, по которому вы можете найти первую часть общего ресурса в Хранилище, а также другая информация, такая как адрес нашего закрытого ключа.


  • Shares содержит часть Share, а также хранит хешированное значение этой Share. Это сделано для того, чтобы мы могли найти его в базе данных.


  • Сюда попадает любая активность с ключом, например создание ключа и все подписи.

Реализация

Для этого сервиса я использовал язык программирования Rust с фреймворком Actix-web. Я постоянно использую их на работе, так почему бы и нет?


Как я уже сказал, базой данных будет 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() }

Здесь нам нужно знать только идентификатор Share, к которому мы запрещаем доступ. В будущем, если я сделаю веб-интерфейс, с ним будет проще работать. Нам не нужен здесь наш ключ 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, там весь код будет доступен =)

Заключение

Хотя это все еще черновой вариант приложения, важно отметить, что это не просто концепция. это работоспособный и многообещающий проект. Также в репозитории есть интеграционные тесты, чтобы понять, как это работает. В следующих частях я планирую добавить подпись транзакций и дать возможность ограничить сферу их использования. Затем я могу создать веб-интерфейс и сделать этот проект удобным для обычного человека.


У этой разработки достаточно большой потенциал для использования, и я надеюсь, что мне удастся раскрыть хотя бы часть его; Прошу прощения за эти страницы кода и редкие комментарии. Я предпочитаю писать код, а не объяснять то, что написал выше. Я постараюсь поправиться, но сейчас он сильнее меня.


Также приветствуются комментарии по коду и пиар при желании =)


Наилучшие пожелания всем и храните свои личные ключи в безопасности.