paint-brush
Hệ thống quản lý khóa: Mọi thứ bạn cần biếtby@vivalaakam
231

Hệ thống quản lý khóa: Mọi thứ bạn cần biết

Andrey Makarov25m2024/07/16
Read on Terminal Reader

Dịch vụ sẽ lưu trữ khóa riêng 256 bit (được sử dụng trong hầu hết các mạng blockchain) Nó sẽ ký các giao dịch và tin nhắn cho mạng EVM (có thể thực hiện hỗ trợ cho các mạng khác sau) Khóa riêng của người dùng không được rời khỏi dịch vụ của chúng tôi. Các khóa chúng tôi chia sẻ phải là duy nhất. Chúng ta nên có nhật ký hoạt động với mỗi phím.
featured image - Hệ thống quản lý khóa: Mọi thứ bạn cần biết
Andrey Makarov HackerNoon profile picture

Cách đây không lâu, tại một công ty Muggle, chúng tôi có một câu hỏi về việc lưu trữ khóa riêng cho các dịch vụ khác nhau. Trong quá trình này, chúng tôi đã tìm ra một số giải pháp không phù hợp lắm vì nhiều lý do khác nhau. Tôi quyết định quay lại với nó một lúc nào đó.

Yêu cầu đối với dịch vụ

Đối với bản thân tôi, tôi đã nêu bật một số yêu cầu để dịch vụ này hoạt động:

  • Hiện tại, dịch vụ phải lưu trữ khóa riêng 256 bit (được sử dụng trong hầu hết các mạng blockchain)
  • Nên ký giao dịch và tin nhắn cho mạng EVM (hỗ trợ cho các mạng khác có thể thực hiện sau).
  • Một người dùng có thể có số lượng khóa không giới hạn.
  • Khóa riêng của người dùng không được rời khỏi dịch vụ của chúng tôi.
  • Mỗi khóa có thể được chia sẻ với số lượng người không giới hạn.
  • Các khóa chúng tôi chia sẻ phải là duy nhất.
  • Chúng ta nên có nhật ký hoạt động với mỗi phím.
  • nếu dịch vụ sử dụng sk Nếu khóa bị xâm phạm, chúng tôi có thể thu hồi nó.


Trong quá trình làm việc, khi nhận ra nhiệm vụ này cần giải quyết như thế nào, tôi nhấn mạnh thêm một yêu cầu:

  • Chúng ta phải có khả năng giới hạn phạm vi của từng khóa. Tuy nhiên, vì công việc đang được tiến hành nên tôi để lại cho bài viết tiếp theo. Vì vậy, chúng tôi sẽ hạn chế ký tên vào tin nhắn trong bài viết này.

Các lựa chọn để giải quyết vấn đề

Vì key sẽ được sử dụng nhiều lần trong bài viết nên mình sẽ gọi public key pk để tránh nhầm lẫn và key được chia sẻ là sk .


Tùy chọn ban đầu là mã hóa khóa trong hàm pbkdf2 , nhưng có vấn đề về cách chia sẻ khóa để truy cập chữ ký vì chúng ta chỉ có một khóa trong quá trình thực hiện thuật toán này.


Tôi tìm thấy hai lựa chọn để giải quyết vấn đề:

  1. Chúng tôi có khóa chính của khóa được mã hóa được lưu trữ trong cơ sở dữ liệu và đã chia sẻ khóa được tạo, dẫn đến khóa gốc. Tôi sẽ không nói rằng tôi thích tùy chọn này vì nếu bạn có quyền truy cập vào cơ sở dữ liệu, khóa pk rất dễ giải mã.


  2. Chúng tôi tạo một phiên bản riêng của khóa pk cho mỗi khóa chúng tôi kiểm tra. Tôi cũng không nói rằng tôi thích lựa chọn này lắm.


Vì vậy, khi đi loanh quanh và suy nghĩ về cách làm cho khóa sk trở nên thuận tiện, tôi nhớ rằng khi sử dụng Chia sẻ bí mật Shamir (SSS), bạn có thể làm cho khóa sk trở nên độc đáo và chỉ chia sẻ một phần của khóa. Phần còn lại sẽ được lưu trữ ở phần phụ trợ trong bảo mật và bạn có thể đưa những phần này cho bất kỳ ai bạn muốn.


Nó sẽ trông như thế này: chúng tôi mã hóa khóa pk bằng khóa do SSS tạo, lưu trữ một phần khóa trong các kho lưu trữ khác nhau và cung cấp một phần khóa cho người dùng dưới dạng sk . Sau khoảng 10-15 phút, tôi nhận ra một điều đơn giản:


Khi sử dụng SSS, chúng ta không cần mã hóa khóa pk của mình bằng bất kỳ thứ gì khác vì SSS có thể xử lý nó một chút và theo tôi, giải pháp này là hoàn hảo để lưu trữ khóa PK. Nó luôn được tháo rời thành các phần bằng cách sử dụng các tùy chọn lưu trữ khác nhau, bao gồm cả của người dùng. Nếu cần thu hồi nó, chúng tôi sẽ xóa thông tin chỉ mục của khóa sk của mình và nhanh chóng tập hợp một thông tin mới.


Trong bài viết này, tôi sẽ không tập trung vào các nguyên tắc của SSS; Tôi đã viết một bài báo ngắn về chủ đề này và nhiều nguyên tắc trong bài viết này sẽ tạo thành nền tảng cho dịch vụ mới của chúng tôi.

Ngành kiến trúc

Nguyên tắc phục vụ của chúng tôi sẽ như sau:

  1. Người dùng chọn tạo khóa.


  2. Chúng tôi tạo một khóa phù hợp cho dịch vụ. Nó sẽ là chìa khóa pk của chúng tôi. Nó không bao giờ rời khỏi dịch vụ nói chung.


  3. Bằng cách sử dụng SSS, chúng tôi chia khóa của mình sao cho cần có ba phần của khóa phân tách để khôi phục khóa pk . Mỗi khóa chia gồm hai phần: x: vị trí khóa của chúng ta y: giá trị cho vị trí này


  4. Chúng tôi đưa phần đầu tiên vào Vault (có thể là bất kỳ dịch vụ nào lưu trữ thông tin nhạy cảm có thể được truy cập qua API).


  5. Phần thứ hai chúng tôi lưu vào cơ sở dữ liệu (Tôi sẽ sử dụng PostgreSQL).


  6. Phần thứ ba chúng tôi lưu một phần vào cơ sở dữ liệu và phần còn lại chúng tôi cung cấp cho người dùng ( sk ). Để sử dụng SK tìm giá trị cần thiết, chúng ta cũng lưu keccak256(sk) vào cơ sở dữ liệu. Theo tôi biết thì nó vẫn chưa bị hỏng.


  7. Khi người dùng cần ký nội dung nào đó, chúng tôi sẽ thu thập khóa riêng tư từ các phần khác nhau của ứng dụng và ký vào đó.


Cách tiếp cận này có một nhược điểm, nếu quản trị viên khóa sk mất tất cả các khóa sk do anh ta tạo ra, chúng tôi không thể khôi phục lại khóa ban đầu. Ngoài ra, bạn có thể tạo bản sao lưu của khóa gốc, nhưng đó là lúc khác =).

Cơ sở dữ liệu

Kết quả công việc của tôi là tôi có cấu trúc cơ sở dữ liệu này:

  • người dùng lưu trữ thông tin về người dùng hay còn gọi là quản trị viên chính.


  • khóa lưu trữ thông tin cơ bản về khóa, chẳng hạn như phần thứ hai của Chia sẻ của chúng tôi, chỉ mục mà bạn có thể tìm thấy phần đầu tiên của Chia sẻ trong Vault và các thông tin khác như địa chỉ khóa riêng của chúng tôi.


  • cổ phiếu chứa một phần Chia sẻ và cũng lưu trữ giá trị băm của Chia sẻ này. Điều này được thực hiện để chúng ta có thể tìm thấy nó trong cơ sở dữ liệu.


  • ghi lại mọi hoạt động bằng khóa, chẳng hạn như tạo khóa và tất cả chữ ký, đều nằm ở đây.

Hiện thực hóa

Tôi đã sử dụng ngôn ngữ lập trình Rust với khung Actix-web cho dịch vụ này. Tôi sử dụng chúng mọi lúc tại nơi làm việc, vậy tại sao không?


Như tôi đã nói, cơ sở dữ liệu sẽ là Postgresql vì những lý do sau.

đa thức

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

Tôi sẽ thú nhận một chút ở đây: Tôi không phải là nhà toán học. Và trong khi tôi cố gắng tìm càng nhiều thông tin về điều này càng tốt, trên thực tế, đây là mã được điều chỉnh từ bài viết trước của tôi.


Bạn có thể đọc thêm về tính năng này tại đây https://en.wikipedia.org/wiki/Lagrange_polynomial


Cấu trúc này (hoặc lớp, tùy theo cách nào thuận tiện hơn) thực hiện phần quan trọng nhất của quy trình mà chúng tôi đã mô tả hôm nay - chia khóa pk thành nhiều phần và lắp ráp lại nó.

Tạo người dùng

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

Ở đây, mọi thứ đều đơn giản nhất có thể; chúng tôi tạo một người dùng có khóa chính để làm việc với các khóa của mình. Điều này được thực hiện để ngăn chặn bất kỳ bên nào khác làm bất cứ điều gì với chìa khóa của chúng tôi. Lý tưởng nhất là khóa này không nên được phân phối dưới bất kỳ hình thức nào.

Tạo khóa

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

Kiểm tra người dùng xem người dùng đó có tồn tại hay không, tạo khóa pk , chia khóa thành nhiều phần và lưu chúng ở những nơi khác nhau.

Cấp phép truy cập

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

Cơ chế hoạt động của chức năng này như sau:


Chúng tôi xác minh rằng người yêu cầu quyền truy cập có tất cả các quyền đối với Chia sẻ.


Chúng ta cần key bí mật ở đây vì một lý do rất đơn giản, nếu không có nó chúng ta không thể khôi phục key pk gốc. Tạo một Chia sẻ bổ sung và cung cấp cho người dùng.

Thu hồi truy cập

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

Ở đây, chúng tôi chỉ cần biết mã định danh của Chia sẻ mà chúng tôi đang thu hồi quyền truy cập. Trong tương lai, nếu tôi tạo một giao diện web thì việc làm việc với nó sẽ dễ dàng hơn. Chúng tôi không cần khóa sk ở đây vì chúng tôi không khôi phục khóa riêng ở đây.

Ký tin nhắn

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

Đã nhận được tin nhắn, nếu mọi thứ đều ổn, hãy khôi phục khóa riêng và ký vào tin nhắn.


Về cơ bản, các phương pháp chính trong ứng dụng của chúng tôi đã được mô tả; Tôi quyết định tiếc nuối và không đưa toàn bộ mã vào đây. Có GitHub cho điều đó và tất cả mã sẽ có sẵn ở đó =)

Phần kết luận

Mặc dù đây vẫn là bản nháp của ứng dụng nhưng điều quan trọng cần lưu ý là đây không chỉ là một khái niệm. đó là một dự thảo khả thi và đầy hứa hẹn. Ngoài ra còn có các bài kiểm tra tích hợp trong kho lưu trữ để hiểu cách thức hoạt động của nó. Trong các phần sau, tôi dự định thêm chữ ký của các giao dịch và giúp giới hạn phạm vi sử dụng của chúng. Sau đó tôi có thể tạo một giao diện web và làm cho dự án này trở nên thân thiện với người dùng bình thường.


Sự phát triển này có khá nhiều tiềm năng sử dụng và tôi hy vọng rằng mình có thể tiết lộ ít nhất một phần của nó; Tôi xin lỗi vì những trang mã này và những bình luận thưa thớt. Tôi thích viết mã hơn là giải thích những gì tôi đã viết ở trên. Tôi sẽ cố gắng trở nên tốt hơn, nhưng bây giờ điều này mạnh mẽ hơn tôi.


Ngoài ra, hoan nghênh các ý kiến về mã và PR nếu muốn =)


Lời chúc tốt đẹp nhất đến tất cả mọi người và giữ cho khóa riêng của bạn được an toàn.