cyrtophora

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit d68beaeaef586438bb3000177963b27ed751ce1c
parent 348e209ce06e6a2d7ff368a793553bc7f19a59b1
Author: seestem <seestem@noreply.codeberg.org>
Date:   Sun, 21 Aug 2022 14:38:20 +0200

Merge pull request 'accounts' (#2) from accounts into root

Reviewed-on: https://codeberg.org/seestem/cyrtophora/pulls/2

Diffstat:
MREADME.md | 18+-----------------
Adoc/accounts.md | 37+++++++++++++++++++++++++++++++++++++
Ddoc/style.css | 202-------------------------------------------------------------------------------
Asrc/account.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Csrc/lib.rs -> src/crypto.rs | 0
Msrc/lib.rs | 194++-----------------------------------------------------------------------------
Asrc/validate.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 234 insertions(+), 410 deletions(-)

diff --git a/README.md b/README.md @@ -13,24 +13,8 @@ ___ ░▀▀▀░░▀░░▀░▀░░▀░░▀▀▀░▀░░░▀░▀░▀▀▀░▀░▀░▀░▀ ``` -User management protocol. The philosophy is that with the right -cryptography and data handling strategies you don't need to -self-host to have freedom/control and privacy. - -## Features - -- [ ] Confidentiality -- [ ] Integrity -- [ ] Availability -- [ ] Authentication -- [ ] Validation -- [ ] Session management -- [ ] Lock-in Free - -## Cyphers - Cyrtophora is built on top of the following cyphers: - [scrypt](https://www.tarsnap.com/scrypt.html): Used for password hashing. - [ed25519](https://ed25519.cr.yp.to/): Digital Signatures -- [XChaCha20-Poly1305](https://libsodium.gitbook.io/doc/secret-key_cryptography/aead/chacha20-poly1305/xchacha20-poly1305_construction): Symmetric Encryption +- [XChaCha20-Poly1305](https://libsodium.gitbook.io/doc/secret-key_cryptography/aead/chacha20-poly1305/xchacha20-poly1305_construction): Symmetric Encryption diff --git a/doc/accounts.md b/doc/accounts.md @@ -0,0 +1,37 @@ +# Accounts + +```rust +pub struct Account { + /// The username of the user, also used as an unique identifier + pub username: String, + /// The password of the user + pub password: String, + /// The email address the user + pub email: Option<String>, +} +``` + +## Mandatory Fields + +An `Account` contains two mandatory fields. + +### `username` + +Twitter style username, used as a unique user identifier. + +- cannot start with a underscore (_) +- can only contain letters, numbers, and one underscore +- can have only one (_) underscore + + +### `password` + +Users master key. + +- should be at least 10 characters long + +## Optional Fields + +### `email` + +The email address the user. diff --git a/doc/style.css b/doc/style.css @@ -1,202 +0,0 @@ - -html { - line-height: 1.5; - font-family: Georgia, serif; - font-size: 20px; - color: #ffffff; - background-color: #222D31 ; -} -body { - margin: 0 auto; - max-width: 36em; - padding-left: 50px; - padding-right: 50px; - padding-top: 0px; - margin-top: 0px; - padding-bottom: 50px; - hyphens: auto; - word-wrap: break-word; - text-rendering: optimizeLegibility; - font-kerning: normal; -} - -@media (max-width: 600px) { - body { - font-size: 0.9em; - padding: 1em; - } -} -@media print { - body { - background-color: transparent; - color: black; - font-size: 12pt; - } - p, h2, h3 { - orphans: 3; - widows: 3; - } - h2, h3, h4 { - page-break-after: avoid; - } - #TOC{ - position: relative !important; - width: inherit; - height: inherit; - border: none; - } -} -p { - margin: 1em 0; -} -a { - color: cyan; -} -a:visited { - color: cyan; -} -img { - max-width: 100%; -} -h1, h2, h3, h4, h5, h6 { - margin-top: 1.4em; - color: cyan; -} -h5, h6 { - font-size: 1em; - font-style: italic; -} -h6 { - font-weight: normal; -} -ol, ul { - padding-left: 1.7em; - margin-top: 1em; -} -li > ol, li > ul { - margin-top: 0; -} -blockquote { - margin: 1em 0 1em 1.7em; - padding-left: 1em; - border-left: 2px solid #e6e6e6; - color: #606060; -} -code { - font-family: Menlo, Monaco, 'Lucida Console', Consolas, monospace; - font-size: 85%; - margin: 0; -} -pre { - margin: 1em 0; - overflow: auto; -} -pre code { - padding: 0; - overflow: visible; -} -.sourceCode { - background-color: transparent; - overflow: visible; -} - -hr { - background-color: cyan; - border: none; - height: 2px; - margin: 4em 0; -} -table { - margin: 1em 0; - border-collapse: collapse; - width: 100%; - overflow-x: auto; - display: block; - font-variant-numeric: lining-nums tabular-nums; -} -table caption { - margin-bottom: 0.75em; -} -tbody { - margin-top: 0.5em; - border-top: 1px solid cyan; - border-bottom: 1px solid cyan; -} -th { - border-top: 1px solid cyan; - padding: 0.25em 0.5em 0.25em 0.5em; -} -td { - padding: 0.125em 0.5em 0.25em 0.5em; -} -header { - margin-bottom: 4em; - text-align: center; -} - -#TOC{ - position: fixed; - width: 20vw; - height: 100vh; - left: 0; - top: 0; - border-right: thin solid cyan; - padding-left: 1em; - overflow: auto; -} - -#TOC ul { - padding-left: .5em; -} - -#TOC li { - list-style: circle; - width: 100% -} - -#TOC a:not(:hover) { - text-decoration: none; -} - -pre{ - border: thin solid cyan; - padding: 1em; - font-family: monospace !Important; - font-size: 18px; -} - -blockquote{ - border-left: 2px solid cyan; - color: #7E807E; -} - -@media only screen and (max-width: 1300px) { - #TOC{ - position: relative !important; - width: inherit; - height: inherit; - border: none; - } - - pre{ - font-size: 16px; - } -} - -@media only screen and (max-width: 480px) { - pre{ - font-size: 12px; - } -} - -@media only screen and (max-width: 370px) { - pre{ - font-size: 10px; - } -} - -@media only screen and (max-width: 300px) { - pre{ - font-size: 7px; - } -} diff --git a/src/account.rs b/src/account.rs @@ -0,0 +1,47 @@ +use crate::validate::{Validate, ValidationError}; + +/// An account +pub struct Account { + /// The username of the user, also used as an unique identifier + pub username: String, + /// The password of the user + pub password: String, + /// The email address the user + pub email: Option<String>, +} + +impl Account { + /// Register new user + pub fn register( + username: &str, + password: &str, + email: Option<&str>, + ) -> Result<Self, ValidationError> { + let mut validated_email: Option<String> = None; + + if !Validate::username(username) { + return Err(ValidationError::Username); + } + + if !Validate::password(password) { + return Err(ValidationError::Email); + } + + if let Some(email) = email { + if !Validate::email(email) { + return Err(ValidationError::Email); + } + validated_email = Some(email.to_string()); + + // TODO: email verification code + } + + // TODO: database registration code + + Ok(Account { + username: username.to_string(), + email: validated_email, + password: password.to_string(), + }) + } +} diff --git a/src/lib.rs b/src/crypto.rs diff --git a/src/lib.rs b/src/lib.rs @@ -1,191 +1,3 @@ -use anyhow::Result; -use chacha20poly1305::{ - aead::{Aead, NewAead}, - Key, XChaCha20Poly1305, XNonce, -}; -use ring::{ - digest::{self, digest, Digest}, - rand::SystemRandom, - signature::{Ed25519KeyPair, KeyPair}, -}; -use scrypt::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, - Scrypt, -}; - -const CREDENTIAL_LEN: usize = digest::SHA256_OUTPUT_LEN; - -/// Nonce used when encrypting the Crypto's keypair (Crypto::encrypt_keypair_document()) -const AED_NONCE: &str = "people the information "; -pub type Credential = [u8; CREDENTIAL_LEN]; - -pub struct Credentials { - /// Base64 encoded key pair serialized as a PKCS#8 document - pub keypair_doc: String, - /// Base64 encoded public key - pub public_key: String, -} - -pub struct Crypto {} - -impl Crypto { - /// Generate Ed25519 key pair, for signing - pub fn gen_ed25519_document() -> Result<Credentials> { - let rng = SystemRandom::new(); - // TODO: don't use expect() - let keypair_document = - Ed25519KeyPair::generate_pkcs8(&rng).expect("Could not generate Ed25519KeyPair"); - let keypair = Ed25519KeyPair::from_pkcs8(keypair_document.as_ref()) - .expect("Could not decode keypair from keypair document"); - let public_key_string = base64::encode(keypair.public_key().as_ref()); - let keypair_document_string = base64::encode(keypair_document.as_ref()); - let cred = Credentials { - keypair_doc: keypair_document_string, - public_key: public_key_string, - }; - - Ok(cred) - } - - /// Generate scrypt based hashed password - pub fn hash_password(password: &str) -> Result<String> { - let salt = SaltString::generate(&mut OsRng); - Ok(Scrypt - .hash_password(password.as_bytes(), &salt)? - .to_string()) - } - - /// Verify scrypt based password hash - fn verify_password_hash(attempted_password: &str, hash: &str) -> Result<bool> { - let parsed_hash = PasswordHash::new(hash)?; - - if Scrypt - .verify_password(attempted_password.as_bytes(), &parsed_hash) - .is_ok() - { - Ok(true) - } else { - Ok(false) - } - } - - /// Encrypt the keypair (ed25519) document, using XChaCha20Poly1305 - /// so that it can be safely persisted into the database. The - /// Crypto's login password is used a the secret key. - pub fn encrypt_keypair_document(keypair_document: &str, password: &str) -> Result<String> { - let secret_key = Crypto::gen_secret_key(password); - let key = Key::from_slice(&secret_key.as_ref()[..32]); // 32-bytes - let aead = XChaCha20Poly1305::new(key); - let nonce = XNonce::from_slice(AED_NONCE.as_bytes()); // 24-bytes; unique - let keypair_document_bytes = base64::decode(keypair_document)?; - let res = base64::encode( - aead.encrypt(nonce, keypair_document_bytes.as_ref()) - .expect("encryption failure!"), - ); - Ok(res) - } - - /// Decrypt the encrypted keypair (ed25519), using XChaCha20Poly1305 - fn decrypt_keypair_document(cyphertext: &str, password: &str) -> Result<String> { - let secret_key = Crypto::gen_secret_key(password); - let key = Key::from_slice(&secret_key.as_ref()[..32]); // 32-bytes - let nonce = XNonce::from_slice(AED_NONCE.as_bytes()); // 24-bytes; unique - let aead = XChaCha20Poly1305::new(key); - let cyphertext_bytes = base64::decode(cyphertext)?; - let res = base64::encode( - aead.decrypt(nonce, cyphertext_bytes.as_ref()) - .expect("decryption failure!"), - ); - Ok(res) - } - - /// Generate secret key, used to encrypt the ed25519 keypair - fn gen_secret_key(password: &str) -> Digest { - digest(&digest::SHA256, password.as_bytes()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ring::signature::{Ed25519KeyPair, KeyPair, UnparsedPublicKey, ED25519}; - - #[test] - fn hash_password() { - let password1 = "a bad password"; - let hash1 = Crypto::hash_password(password1).unwrap(); - - let password2 = "a bad password"; - let hash2 = Crypto::hash_password(password2).unwrap(); - - // The generated hashes are all 88 bytes long - assert_eq!(hash1.len(), 88); - assert_eq!(hash2.len(), 88); - - // Even if the user passwords are the same they do not generate - // the same hashes because the username is also used as a - // seed when the hash is generated. - assert_ne!(hash1, hash2); - - // Verify password - assert!(Crypto::verify_password_hash("a bad password", &hash1).unwrap()); - assert!(!Crypto::verify_password_hash("wrong password", &hash1).unwrap()); - } - - #[test] - fn gen_keypairs() { - let cred = Crypto::gen_ed25519_document().unwrap(); - let document = base64::decode(cred.keypair_doc).unwrap(); - let key_pair = Ed25519KeyPair::from_pkcs8(&document).unwrap(); - - // Sign the message "hello, world". - const MESSAGE: &[u8] = b"hello, world"; - let sig = key_pair.sign(MESSAGE); - - // Normally an application would extract the bytes of the signature and - // send them in a protocol message to the peer(s). Here we just get the - // public key key directly from the key pair. - let peer_public_key_bytes = key_pair.public_key().as_ref(); - - // Verify the signature of the message using the public key. Normally the - // verifier of the message would parse the inputs to this code out of the - // protocol message(s) sent by the signer. - let peer_public_key = UnparsedPublicKey::new(&ED25519, peer_public_key_bytes); - peer_public_key.verify(MESSAGE, sig.as_ref()).unwrap(); - } - - #[test] - fn symmetric_encryption_decryption() { - let password = "my very weak password"; - let cred = Crypto::gen_ed25519_document().unwrap(); - let document = base64::decode(cred.keypair_doc.clone()).unwrap(); - let expected_key_pair = Ed25519KeyPair::from_pkcs8(&document).unwrap(); - - let wrong_cred = Crypto::gen_ed25519_document().unwrap(); - let wrong_document = base64::decode(wrong_cred.keypair_doc).unwrap(); - let wrong_key_pair = Ed25519KeyPair::from_pkcs8(&wrong_document).unwrap(); - - // encrypt the document - let cyphertext = Crypto::encrypt_keypair_document(&cred.keypair_doc, password).unwrap(); - let cleartext = Crypto::decrypt_keypair_document(&cyphertext, password).unwrap(); - - let cleartext_bytes = base64::decode(cleartext.clone()).unwrap(); - let key_pair = Ed25519KeyPair::from_pkcs8(cleartext_bytes.as_ref()).unwrap(); - - assert_ne!(cyphertext, cleartext); - assert_eq!( - expected_key_pair.public_key().as_ref(), - key_pair.public_key().as_ref() - ); - - assert_ne!( - expected_key_pair.public_key().as_ref(), - wrong_key_pair.public_key().as_ref() - ); - - assert_ne!( - key_pair.public_key().as_ref(), - wrong_key_pair.public_key().as_ref() - ); - } -} +mod account; +mod crypto; +mod validate; diff --git a/src/validate.rs b/src/validate.rs @@ -0,0 +1,146 @@ +pub struct Validate; + +impl Validate { + /// Validate username (using Twitter style usernames) + pub fn username(username: &str) -> bool { + // Username cannot be empty + if username.is_empty() { + return false; + } + + // Username length cannot be greater than 15 characters + if username.len() > 15 { + return false; + } + + let chars: Vec<char> = username.chars().collect(); + let mut underscore_count = 0; + + for i in 0..chars.len() { + // Username cannot start with a underscore (_) + if i == 0 && chars[i] == '_' { + return false; + } + + // Username can only contain letters, numbers, and one underscore + if chars[i] != '_' { + if !chars[i].is_ascii_alphanumeric() { + return false; + } + } else { + // Count underscores because username can have only one (_) underscore + underscore_count += 1; + } + } + + // Username can have only one (_) underscore + if underscore_count > 1 { + return false; + } + true + } + + /// TODO: better email validation + pub fn email(email: &str) -> bool { + // Email address cannot be empty + if email.is_empty() { + return false; + } + + // Email address should contain @ and . symbols + if !email.contains('@') || !email.contains('.') { + return false; + } + true + } + + /// TODO: better password validation + pub fn password(password: &str) -> bool { + // Password should be at least 10 characters long + if password.len() < 10 { + return false; + } + true + } +} + +use std::fmt; + +#[derive(Debug)] +pub enum ValidationError { + Username, + Email, + Password, +} + +impl std::error::Error for ValidationError {} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ValidationError::Username => write!(f, "Invalid username"), + ValidationError::Email => write!(f, "Invalid email"), + ValidationError::Password => write!(f, "Invalid password"), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn username_validation() { + let valid_username = "firephoenix"; + let valid_username1 = "natty_dread"; + let valid_username2 = "ironlion_ironii"; + let invalid_username = ""; + let invalid_username1 = "natty_dread_iron"; + let invalid_username2 = "_natty_dread_iron"; + let invalid_username3 = "nattydre@d"; + let invalid_username4 = "nattydread$"; + let invalid_username5 = "firephoenixfirephoenixfirephoenixfirephoenixfirephoenix"; + + assert!(Validate::username(valid_username)); + assert!(Validate::username(valid_username1)); + assert!(Validate::username(valid_username2)); + + // username cannot be empty + assert!(!Validate::username(invalid_username)); + // username cannot contain more than 1 underscore + assert!(!Validate::username(invalid_username1)); + // username cannot start with an underscore + assert!(!Validate::username(invalid_username2)); + // username cannot contain special symbols (only the _ is allowed) + assert!(!Validate::username(invalid_username3)); + assert!(!Validate::username(invalid_username4)); + // username cannot be more than 15 characters long + assert!(!Validate::username(invalid_username5)); + } + + #[test] + fn email_validation() { + let valid_email = "example@example.com"; + let valid_email1 = "test@gmail.com"; + let invalid_email = "kdkdf dfkfd@uurt"; + let invalid_email2 = "lolz.lolz"; + let invalid_email3 = "lolz@lolz"; + + assert!(Validate::email(valid_email)); + assert!(Validate::email(valid_email1)); + + assert!(!Validate::email(invalid_email)); + assert!(!Validate::email(invalid_email2)); + assert!(!Validate::email(invalid_email3)); + } + + #[test] + fn password_validation() { + let valid_password = "passwordShoul be at least 10 chars"; + let invalid_password = "lolz"; + + assert!(Validate::password(valid_password)); + // Password should be at least 10 characters long + assert!(!Validate::password(invalid_password)); + } +}