paint-brush
Schlüsselverwaltungssystem: Alles, was Sie wissen müssenby@vivalaakam
231

Schlüsselverwaltungssystem: Alles, was Sie wissen müssen

Andrey Makarov25m2024/07/16
Read on Terminal Reader

Der Dienst speichert einen 256-Bit-Privatschlüssel (wird in den meisten Blockchain-Netzwerken verwendet). Er signiert Transaktionen und Nachrichten für EVM-Netzwerke (Unterstützung für andere Netzwerke kann später erfolgen). Der Privatschlüssel eines Benutzers darf unseren Dienst nicht verlassen. Die Schlüssel, die wir teilen, müssen eindeutig sein. Wir sollten ein Aktivitätsprotokoll mit jedem Schlüssel haben.
featured image - Schlüsselverwaltungssystem: Alles, was Sie wissen müssen
Andrey Makarov HackerNoon profile picture

Vor nicht allzu langer Zeit hatten wir bei einem Muggeljob eine Frage zur Speicherung privater Schlüssel für verschiedene Dienste. Dabei sind wir auf mehrere Lösungen gestoßen, die aus unterschiedlichen Gründen nicht besonders geeignet waren. Ich habe beschlossen, irgendwann noch einmal darauf zurückzukommen.

Voraussetzungen für den Dienst

Für mich persönlich habe ich einige Voraussetzungen hervorgehoben, damit dieser Dienst funktioniert:

  • Derzeit muss der Dienst einen 256-Bit-Privatschlüssel speichern (wird in den meisten Blockchain-Netzwerken verwendet).
  • Sollte Transaktionen und Nachrichten für EVM-Netzwerke signieren (Unterstützung für andere Netzwerke kann später erfolgen).
  • Ein Benutzer kann über eine unbegrenzte Anzahl an Schlüsseln verfügen.
  • Der private Schlüssel eines Benutzers darf unseren Dienst nicht verlassen.
  • Jeder Schlüssel kann mit einer unbegrenzten Anzahl von Personen geteilt werden.
  • Die Schlüssel, die wir teilen, müssen eindeutig sein.
  • Wir sollten für jeden Schlüssel ein Aktivitätsprotokoll haben.
  • Wenn der Dienst, der den sk Schlüssel verwendet, kompromittiert ist, sollten wir in der Lage sein, ihn zu widerrufen.


Bereits im Arbeitsprozess, als mir klar wurde, wie diese Aufgabe gelöst werden sollte, habe ich eine weitere Anforderung hervorgehoben:

  • Wir müssen in der Lage sein, den Umfang jedes Schlüssels einzuschränken. Da die Arbeit jedoch bereits im Gange war, habe ich sie für den nächsten Artikel aufgehoben. Daher beschränken wir uns in diesem Artikel auf das Signieren der Nachricht.

Optionen zur Lösung des Problems

Da der Schlüssel in diesem Artikel mehrfach verwendet wird, nenne ich den privaten Schlüssel, um Verwirrung zu vermeiden, pk und den gemeinsam genutzten Schlüssel sk .


Die ursprüngliche Option bestand darin, den Schlüssel in der Funktion pbkdf2 zu verschlüsseln, es gab jedoch ein Problem bei der Freigabe des Schlüssels für den Zugriff auf die Signatur, da wir im Prozess dieses Algorithmus nur über einen Schlüssel verfügen.


Ich habe zwei Möglichkeiten zur Lösung des Problems gefunden:

  1. Wir haben einen Hauptschlüssel des verschlüsselten Schlüssels in der Datenbank gespeichert und haben den generierten Schlüssel, der zum Originalschlüssel führt, bereits freigegeben. Ich würde nicht sagen, dass mir diese Option gefällt, denn wenn man Zugriff auf die Datenbank erhält, ist der pk Schlüssel leicht zu entschlüsseln.


  2. Wir erstellen für jeden Schlüssel, den wir prüfen, eine separate Instanz unseres pk Schlüssels. Ich würde auch nicht sagen, dass mir diese Option besonders gefällt.


Als ich also herumging und darüber nachdachte, wie ich den SK-Schlüssel praktischer gestalten könnte, fiel mir ein, dass man bei Verwendung von Shamir Secrets Sharing (SSS) den sk Schlüssel eindeutig machen und nur einen Teil des Schlüssels freigeben kann. Der Rest wird sicher im Backend gespeichert und man kann diese Teile an jeden weitergeben, den man möchte.


Das würde so aussehen: Wir verschlüsseln den pk-Schlüssel mit unserem SSS-generierten Schlüssel, speichern einen Teil des Schlüssels in verschiedenen Speichern und geben einen Teil des Schlüssels dem Benutzer als sk . Nach etwa 10-15 Minuten wurde mir eine einfache Sache klar:


Wenn wir SSS verwenden, müssen wir unseren pk Schlüssel nicht mit irgendetwas anderem verschlüsseln, da SSS das ein wenig handhaben kann, und diese Lösung ist meiner Meinung nach perfekt zum Speichern von PK-Schlüsseln. Er wird immer in Teile zerlegt, die verschiedene Speicheroptionen verwenden, einschließlich der des Benutzers. Wenn er widerrufen werden muss, löschen wir die Indexinformationen unseres sk Schlüssels und stellen schnell einen neuen zusammen.


In diesem Artikel werde ich nicht näher auf die Grundsätze von SSS eingehen. Ich habe zu diesem Thema bereits einen kurzen Artikel verfasst und viele Grundsätze aus diesem Artikel werden die Grundlage unseres neuen Dienstes bilden.

Die Architektur

Das Prinzip unserer Dienstleistung wird wie folgt sein:

  1. Der Benutzer entscheidet sich für die Generierung eines Schlüssels.


  2. Wir erstellen einen passenden Schlüssel für den Dienst. Es wird unser pk Schlüssel sein. Er verlässt den Dienst nie als Ganzes.


  3. Mithilfe von SSS teilen wir unseren Schlüssel auf, sodass drei Teile des geteilten Schlüssels erforderlich sind, um den pk Schlüssel wiederherzustellen. Jeder geteilte Schlüssel besteht aus zwei Teilen: x: die Position unseres Schlüssels y: der Wert für diese Position


  4. Den ersten Teil werfen wir in Vault (das kann jeder beliebige Dienst zum Speichern sensibler Informationen sein, auf den über eine API zugegriffen werden kann).


  5. Den zweiten Teil speichern wir in der Datenbank (ich werde PostgreSQL verwenden).


  6. Den dritten Teil speichern wir teilweise in der Datenbank und den anderen Teil geben wir dem Benutzer ( sk ). Um SK zu verwenden, um den benötigten Wert zu finden, speichern wir auch keccak256(sk) in der Datenbank. Soweit ich weiß, ist es noch nicht kaputt gegangen.


  7. Wenn der Benutzer etwas unterschreiben muss, sammeln wir den privaten Schlüssel aus verschiedenen Teilen der Anwendung und signieren ihn.


Dieser Ansatz hat einen Nachteil: Wenn der sk Schlüsseladministrator alle von ihm generierten sk Schlüssel verliert, können wir den Originalschlüssel nicht wiederherstellen. Alternativ können Sie eine Sicherungskopie des Originalschlüssels erstellen, aber das ist eine Frage für ein anderes Mal =).

Datenbank

Als Ergebnis meiner Arbeit habe ich diese Datenbankstruktur:

  • Benutzer speichert Informationen über den Benutzer bzw. Schlüsseladministrator.


  • keys speichert grundlegende Informationen über den Schlüssel, beispielsweise den zweiten Teil unserer Freigabe, den Index, über den Sie den ersten Teil der Freigabe im Tresor finden können, und andere Informationen, beispielsweise die Adresse unseres privaten Schlüssels.


  • shares enthält einen Teil von Share und speichert auch den Hashwert dieses Shares. Dies geschieht, damit wir es in der Datenbank finden können.


  • Hierher gehört die Protokollierung sämtlicher Aktivitäten mit dem Schlüssel, wie etwa der Schlüsselerstellung und sämtlicher Signaturen.

Realisierung

Ich habe für diesen Dienst die Programmiersprache Rust mit dem Actix-Web-Framework verwendet. Ich verwende sie ständig bei der Arbeit, also warum nicht?


Wie gesagt wird die Datenbank aus folgenden Gründen Postgresql sein.

Polynom

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

Ich muss hier ein kleines Geständnis machen: Ich bin kein Mathematiker. Und obwohl ich versucht habe, so viele Informationen wie möglich darüber zu finden, handelt es sich hier tatsächlich um angepassten Code aus meinem vorherigen Artikel.


Mehr zu dieser Funktion können Sie hier lesen: https://en.wikipedia.org/wiki/Lagrange_polynomial


Diese Struktur (oder Klasse, je nachdem, was praktischer ist) führt den wichtigsten Teil des heute beschriebenen Prozesses aus – das Zerlegen des pk Schlüssels in Teile und anschließendes erneutes Zusammensetzen.

Benutzer erstellen

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

Hier ist alles so einfach wie möglich: Wir erstellen einen Benutzer, der einen Hauptschlüssel hat, um mit seinen Schlüsseln zu arbeiten. Dies geschieht, um zu verhindern, dass jemand anderes mit unseren Schlüsseln etwas anstellt. Im Idealfall sollte dieser Schlüssel in keiner Weise weitergegeben werden.

Schlüssel generieren

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

Überprüfen Sie, ob ein solcher Benutzer vorhanden ist, erstellen Sie einen pk Schlüssel, teilen Sie ihn in Teile auf und speichern Sie diese an verschiedenen Orten.

Zugriff gewähren

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

Der Funktionsmechanismus dieser Funktion ist wie folgt:


Wir überprüfen, ob der Zugriffsanfragende über alle Rechte an der Freigabe verfügt.


Wir benötigen den geheimen Schlüssel hier aus einem ganz einfachen Grund, denn ohne ihn können wir den ursprünglichen pk Schlüssel nicht wiederherstellen. Erstellen Sie eine zusätzliche Freigabe und geben Sie diese an den Benutzer weiter.

Zugangsberechtigung aufheben

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

Hier müssen wir nur die Kennung der Freigabe kennen, für die wir den Zugriff widerrufen. Wenn ich in Zukunft eine Weboberfläche erstelle, wird dies einfacher zu handhaben sein. Wir brauchen unseren sk Schlüssel hier nicht, da wir den privaten Schlüssel hier nicht wiederherstellen.

Nachricht signieren

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

Habe die Nachricht erhalten, wenn alles in Ordnung war, den privaten Schlüssel wiederhergestellt und die Nachricht damit signiert.


Im Wesentlichen sind die Hauptmethoden unserer Anwendung beschrieben. Ich habe mich aus Mitleid entschieden und nicht den gesamten Code hier zu veröffentlichen. Dafür gibt es GitHub, und der gesamte Code wird dort verfügbar sein =)

Abschluss

Obwohl dies noch ein Entwurf der Anwendung ist, ist es wichtig zu beachten, dass es sich nicht nur um ein Konzept handelt. Es ist ein praktikabler Entwurf, der vielversprechend ist. Es gibt auch Integrationstests im Repository, um zu verstehen, wie es funktioniert. In den folgenden Teilen plane ich, die Signatur von Transaktionen hinzuzufügen und es möglich zu machen, den Umfang ihrer Verwendung einzuschränken. Vielleicht erstelle ich dann eine Weboberfläche und mache dieses Projekt für den Durchschnittsbürger benutzerfreundlich.


Diese Entwicklung hat ein ziemlich großes Nutzungspotenzial und ich hoffe, dass ich zumindest einen Teil davon offenlegen kann. Ich entschuldige mich für diese Seiten mit Code und die spärlichen Kommentare. Ich schreibe lieber Code, als zu erklären, was ich oben geschrieben habe. Ich werde versuchen, besser zu werden, aber das ist jetzt stärker als ich.


Kommentare zum Code und PR sind bei Bedarf ebenfalls willkommen =)


Alles Gute für alle und bewahren Sie Ihre privaten Schlüssel sicher auf.