कुछ समय पहले, एक मगल जॉब में, हमारे पास विभिन्न सेवाओं के लिए निजी कुंजियों को संग्रहीत करने के बारे में एक प्रश्न था। इस प्रक्रिया में, हमें कई समाधान मिले जो विभिन्न कारणों से बहुत उपयुक्त नहीं थे। मैंने कभी इस पर वापस आने का फैसला किया।
अपने लिए, मैंने इस सेवा के कार्य करने के लिए कुछ आवश्यकताओं पर प्रकाश डाला है:
sk
कुंजी का उपयोग करने वाली सेवा से समझौता किया गया है, तो हमें इसे रद्द करने में सक्षम होना चाहिए।
काम की प्रक्रिया में, जब मुझे एहसास हुआ कि इस कार्य को कैसे हल किया जाना चाहिए, तो मैंने एक और आवश्यकता पर प्रकाश डाला:
चूंकि कुंजी का उपयोग लेख में कई बार किया जाएगा, इसलिए मैं भ्रम से बचने के लिए निजी कुंजी को pk
कहूंगा और साझा की गई कुंजी को sk
कहूंगा।
प्रारंभिक विकल्प pbkdf2
फ़ंक्शन में कुंजी को एन्क्रिप्ट करना था, लेकिन हस्ताक्षर तक पहुंचने के लिए कुंजी को साझा करने के तरीके में समस्या थी क्योंकि इस एल्गोरिथ्म की प्रक्रिया में हमारे पास केवल एक कुंजी है।
मुझे इस समस्या को हल करने के लिए दो विकल्प मिले:
हमारे पास डेटाबेस में संग्रहीत एन्क्रिप्टेड कुंजी की एक मास्टर कुंजी है और हमने पहले से ही जेनरेट की गई कुंजी को साझा किया है, जो मूल कुंजी की ओर ले जाती है। मैं यह नहीं कहूंगा कि मुझे यह विकल्प पसंद आया क्योंकि यदि आपको डेटाबेस तक पहुंच मिलती है, तो pk
कुंजी को डिक्रिप्ट करना आसान है।
हम प्रत्येक कुंजी की जाँच के लिए अपनी pk
कुंजी का एक अलग उदाहरण बनाते हैं। मैं यह भी नहीं कहूँगा कि मुझे यह विकल्प बहुत पसंद है।
इसलिए, घूमते-घूमते और sk कुंजी को सुविधाजनक बनाने के बारे में सोचते हुए, मुझे याद आया कि शमीर सीक्रेट्स शेयरिंग (SSS) का उपयोग करते समय, आप sk
कुंजी को अद्वितीय बना सकते हैं और कुंजी का केवल एक हिस्सा साझा कर सकते हैं। बाकी हिस्सा सुरक्षा में बैकएंड पर संग्रहीत किया जाएगा, और आप इन भागों को किसी को भी दे सकते हैं।
यह इस तरह दिखेगा: हम अपनी SSS-जनरेटेड कुंजी के साथ pk कुंजी को एन्क्रिप्ट करते हैं, कुंजी के हिस्से को अलग-अलग स्टोरेज में स्टोर करते हैं, और कुंजी का हिस्सा उपयोगकर्ता को sk
के रूप में देते हैं। लगभग 10-15 मिनट के बाद, मुझे एक सीधी बात समझ में आई:
SSS का उपयोग करते समय, हमें अपनी pk
कुंजी को किसी अन्य चीज़ से एन्क्रिप्ट करने की आवश्यकता नहीं होती है क्योंकि SSS इसे थोड़ा संभाल सकता है, और यह समाधान PK कुंजियों को संग्रहीत करने के लिए एकदम सही है, मेरी राय में। इसे हमेशा उपयोगकर्ता सहित विभिन्न भंडारण विकल्पों का उपयोग करके भागों में विभाजित किया जाता है। यदि इसे रद्द करने की आवश्यकता है, तो हम अपनी sk
कुंजी की इंडेक्स जानकारी को हटा देते हैं और जल्दी से एक नई कुंजी को इकट्ठा करते हैं।
इस लेख में, मैं एसएसएस के सिद्धांतों पर विस्तार से चर्चा नहीं करूंगा; मैंने पहले ही इस विषय पर एक छोटा लेख लिखा है और इस लेख के कई सिद्धांत हमारी नई सेवा का आधार बनेंगे।
हमारी सेवा का सिद्धांत इस प्रकार होगा:
उपयोगकर्ता एक कुंजी उत्पन्न करना चुनता है.
हम सेवा के लिए एक उपयुक्त कुंजी बनाते हैं। यह हमारी pk
कुंजी होगी। यह कभी भी पूरी सेवा को नहीं छोड़ती।
SSS का उपयोग करके, हम अपनी कुंजी को विभाजित करते हैं ताकि pk
कुंजी को पुनर्प्राप्त करने के लिए विभाजित कुंजी के तीन भागों की आवश्यकता हो। प्रत्येक विभाजित कुंजी में दो भाग होते हैं: x: हमारी कुंजी की स्थिति y: इस स्थिति के लिए मान
हम पहले भाग को वॉल्ट में डाल देते हैं (यह संवेदनशील जानकारी संग्रहीत करने के लिए कोई भी सेवा हो सकती है जिसे API के माध्यम से एक्सेस किया जा सकता है)।
दूसरे भाग को हम डेटाबेस में सहेजते हैं (मैं PostgreSQL का उपयोग करने जा रहा हूँ)।
तीसरा भाग हम आंशिक रूप से डेटाबेस में सहेजते हैं, और दूसरा भाग हम उपयोगकर्ता ( sk
) को देते हैं। हमें जिस मान की आवश्यकता है उसे खोजने के लिए SK का उपयोग करने के लिए, हम keccak256(sk)
भी डेटाबेस में सहेजते हैं। जहाँ तक मुझे पता है, इसे अभी तक तोड़ा नहीं गया है।
जब उपयोगकर्ता को किसी चीज़ पर हस्ताक्षर करने की आवश्यकता होती है, तो हम एप्लिकेशन के विभिन्न भागों से निजी कुंजी एकत्र करते हैं और उस पर हस्ताक्षर करते हैं।
इस दृष्टिकोण का एक नुकसान यह है कि यदि sk
कुंजी व्यवस्थापक अपनी सभी sk
कुंजियाँ खो देता है जो उसके द्वारा बनाई गई थीं, तो हम मूल कुंजी को वापस नहीं ला सकते। एक विकल्प के रूप में, आप मूल कुंजी का बैकअप बना सकते हैं, लेकिन यह किसी और समय के लिए है =)।
मेरे काम के परिणामस्वरूप, मेरे पास यह डेटाबेस संरचना है:
मैंने इस सेवा के लिए एक्टिक्स-वेब फ्रेमवर्क के साथ रस्ट प्रोग्रामिंग भाषा का उपयोग किया। मैं काम पर हर समय उनका उपयोग करता हूं, तो क्यों नहीं?
जैसा कि मैंने कहा, निम्नलिखित कारणों से डेटाबेस 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 है, और सारा कोड वहाँ उपलब्ध होगा =)
हालांकि यह अभी भी एप्लिकेशन का एक मसौदा है, लेकिन यह ध्यान रखना महत्वपूर्ण है कि यह केवल एक अवधारणा नहीं है। यह एक व्यावहारिक मसौदा है जो वादा दिखाता है। यह समझने के लिए कि यह कैसे काम करता है, रिपॉजिटरी में एकीकरण परीक्षण भी हैं। अगले भागों में, मैं लेन-देन के हस्ताक्षर जोड़ने और उनके उपयोग के दायरे को सीमित करना संभव बनाने की योजना बना रहा हूँ। फिर मैं एक वेब इंटरफ़ेस बना सकता हूँ और इस परियोजना को औसत व्यक्ति के लिए अनुकूल बना सकता हूँ।
इस विकास में उपयोग की काफी संभावनाएं हैं, और मुझे उम्मीद है कि मैं कम से कम इसका कुछ हिस्सा प्रकट कर पाऊंगा; कोड के इन पन्नों और विरल टिप्पणियों के लिए मैं क्षमा चाहता हूँ। मैंने जो ऊपर लिखा है, उसे समझाने के बजाय मैं कोड लिखना पसंद करता हूँ। मैं बेहतर होने की कोशिश करूँगा, लेकिन यह अब मुझसे ज़्यादा मजबूत है।
इसके अलावा, यदि वांछित हो तो कोड और पीआर पर टिप्पणियों का स्वागत है =)
सभी को शुभकामनाएं, और अपनी निजी कुंजी सुरक्षित रखें।