diff --git a/Cargo.lock b/Cargo.lock index 79b5ef3..4f37298 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -216,6 +216,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433cfd6710c9986c576a25ca913c39d66a6474107b406f34f91d4a8923395241" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e1366e0c69c9f927b1fa5ce2c7bf9eafc8f9268c0b9800729e8b267612447c" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.6" @@ -679,30 +704,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", - "zeroize", -] - [[package]] name = "chrono" version = "0.4.24" @@ -727,7 +728,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", - "zeroize", ] [[package]] @@ -936,6 +936,15 @@ dependencies = [ "syn 1.0.107", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.2.5" @@ -1418,6 +1427,16 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gloo-timers" version = "0.2.6" @@ -2201,11 +2220,12 @@ dependencies = [ ] [[package]] -name = "poly1305" -version = "0.8.0" +name = "polyval" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "7ef234e08c11dfcb2e56f79fd70f6f2eb7f025c0ce2333e82f4f0518ecad30c6" dependencies = [ + "cfg-if", "cpufeatures", "opaque-debug", "universal-hash", @@ -3473,8 +3493,8 @@ version = "0.1.0" dependencies = [ "actix-request-identifier", "actix-web", + "aes-gcm", "base64 0.21.0", - "chacha20poly1305", "chrono", "hex", "log", diff --git a/trifid-api/Cargo.toml b/trifid-api/Cargo.toml index e80dbaa..f3467c4 100644 --- a/trifid-api/Cargo.toml +++ b/trifid-api/Cargo.toml @@ -29,4 +29,4 @@ base64 = "0.21.0" # Misc. chrono = "0.4.24" # Misc. trifid-pki = { version = "0.1.9" } # Cryptography -chacha20poly1305 = "0.10.1" # Cryptography \ No newline at end of file +aes-gcm = "0.10.1" # Cryptography \ No newline at end of file diff --git a/trifid-api/config.example.toml b/trifid-api/config.example.toml new file mode 100644 index 0000000..975ec0f --- /dev/null +++ b/trifid-api/config.example.toml @@ -0,0 +1,123 @@ +########################## +# trifid-api config file # +########################## +# trifid-api, an open source reimplementation of the Defined Networking nebula management server. +# Copyright (C) 2023 c0repwn3r +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Please read this file in it's entirety to learn what options you do or don't need to change +# to get a functional trifid-api instance. + +#### [database] #### +# Options related to the PostgreSQL database connection. +[database] +# The PostgreSQL connection URL to connect to the database. +# Example: postgres://username:password@ip:port/database-name. +# The database provided must exist. Database migrations will be run automatically upon database startup. +# Url. Required. +url = "your-database-url-here" + +# The maximum number of connections that will be established to the database. +# This will effectively mean the amount of requests that trifid-api can process in parallel, as almost every +# request handler acquires a connection from the pool. +# Integer. Optional. Default: 100 +# max_connections = 100 + +# The minimum number of connections that will be established to the database. +# At least this number of connections will be created and kept idle until needed. If requests have a lot of latency +# due to acquiring connections from the database, raise this number. +# Integer. Optional. Default = 5 +# min_connections = 5 + +# The maximum amount of time (in seconds) that the database pool will wait in order to connect to the database. +# After this amount of time, the connection will return an error and trifid-api will exit. If you have a very high-latency +# database connection, raise this number. +# Integer. Optional. Default = 8 +# connect_timeout = 8 + +# The maximum amount of time (in seconds) that the database pool will wait in order to acquire a connection from the database pool. +# After this amount of time, the connection will return an error and trifid-api will exit. If you have a very high-latency +# database connection, raise this number. +# Integer. Optional. Default = 8 +# acquire_timeout = 8 + +# The maximum amount of time (in seconds) that a database connection will remain idle before the connection is closed. +# This only applies if closing this connection would not bring the number of connections below min_connections. +# Unless you are handling thousands of requests per second, you probably don't need to change this value. +# Integer. Optional. Default = 8 +# idle_timeout = 8 + +# The maximum amount of time (in seconds) that a database connection will remain active before it is closed and replaced with a new connection. +# It is unlikely you ever need to change this value, unless your database takes 5 or more seconds per query, in which case you +# need a better database. +# Integer. Optional. Default = 8 +# max_lifetime = 8 + +# Should sqlx query logging be enabled? +# Disable this if you are tired of the constant query spam in the logs. Enable for debugging. +# Boolean. Optional. Default = true +# sqlx_logging = true + +#### [server] #### +# Configure options for the trifid-api HTTP server. +[server] +# What IPs and ports should the trifid-api server listen on? +# This may need to be changed if you want to bind on a different port or interface. +# SocketAddr. Optional. Default = 0.0.0.0:8080 (all IPs, port 8080) +# bind = "0.0.0.0:8080" + +#### [tokens] #### +# Configure options related to the various tokens that may be issued by the trifid-api server. +[tokens] +# How long (in seconds) should magic link tokens be valid for? +# This controls how long links sent to user's email addresses will remain valid for login. +# The default of 3600 (1 hour) is a sane default and you likely do not need to change this. +# Integer. Optional. Default = 3600 +# magic_link_expiry_time_seconds = 3600 # 1 hour + +# How long (in seconds) should session tokens be valid for? +# This controls how long it will take before a user will need to re-log in with a magic link, if they do not explicitly +# log out first. +# The default of 15780000 (6 months) is a sane default and you likely do not need to change this. +# Integer. Optional. Default = 15780000 +# session_token_expiry_time_seconds = 15780000 # 6 months + +# How long (in seconds) should TOTP setup tokens be valid for? +# This controls how long a user will have to setup TOTP after starting the setup process before the token is invalidated +# and they need to try again. +# The default of 600 (10 minutes) is a sane default and you likely do not need to change this. +# Integer. Optional. Default = 600 +# totp_setup_timeout_time_seconds = 600 # 10 minutes + +# How long (in seconds) should MFA auth tokens be valid for? +# This controls how long a user will remain logged in before they need to re-input their 2FA code.. +# The default of 600 (10 minutes) is a sane default and you likely do not need to change this. +# Integer. Optional. Default = 600 +# mfa_tokens_expiry_time_seconds = 600 # 10 minutes + +#### [crypto] #### +# Configure settings related to the cryptography used inside trifid-api +[crypto] + +# The per-instance data encryption key to protect sensitive data in the instance. +# YOU ABSOLUTELY NEED TO CHANGE THIS. If you don't change anything else in this file, this should be the one thing you change. +# This should be a 32-byte hex value. Generate it with `openssl rand -hex 32`, or any other tool of your choice. +# If you get "InvalidLength" errors while trying to do anything involving organizations, that indicates that this +# value was improperly generated. +# +# ------- WARNING ------- +# Do not change this value in a production instance. It will make existing data inaccessible until changed back. +# ------- WARNING ------- +data-key = "edd600bcebea461381ea23791b6967c8667e12827ac8b94dc022f189a5dc59a2" \ No newline at end of file diff --git a/trifid-api/src/config.rs b/trifid-api/src/config.rs index 9b454d7..6e5b4c0 100644 --- a/trifid-api/src/config.rs +++ b/trifid-api/src/config.rs @@ -26,7 +26,8 @@ pub static CONFIG: Lazy = Lazy::new(|| { pub struct TrifidConfig { pub database: TrifidConfigDatabase, pub server: TrifidConfigServer, - pub tokens: TrifidConfigTokens + pub tokens: TrifidConfigTokens, + pub crypto: TrifidConfigCryptography } #[derive(Serialize, Deserialize, Debug)] @@ -66,6 +67,11 @@ pub struct TrifidConfigTokens { pub mfa_tokens_expiry_time_seconds: u64 } +#[derive(Serialize, Deserialize, Debug)] +pub struct TrifidConfigCryptography { + pub data_encryption_key: String +} + fn max_connections_default() -> u32 { 100 } fn min_connections_default() -> u32 { 5 } fn time_defaults() -> u64 { 8 } diff --git a/trifid-api/src/crypto.rs b/trifid-api/src/crypto.rs new file mode 100644 index 0000000..a8b7313 --- /dev/null +++ b/trifid-api/src/crypto.rs @@ -0,0 +1,27 @@ +use std::error::Error; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use aes_gcm::aead::{Aead, Payload}; +use rand::Rng; +use trifid_pki::rand_core::OsRng; +use crate::config::TrifidConfig; + +pub fn get_cipher_from_config(config: &TrifidConfig) -> Result> { + let key_slice = hex::decode(&config.crypto.data_encryption_key)?; + Ok(Aes256Gcm::new_from_slice(&key_slice)?) +} + +pub fn encrypt_with_nonce(plaintext: &[u8], nonce: [u8; 12], cipher: &Aes256Gcm) -> Result, aes_gcm::Error> { + let nonce = Nonce::from_slice(&nonce); + let ciphertext = cipher.encrypt(nonce, plaintext)?; + Ok(ciphertext) +} + +pub fn decrypt_with_nonce(ciphertext: &[u8], nonce: [u8; 12], cipher: &Aes256Gcm) -> Result, aes_gcm::Error> { + let nonce = Nonce::from_slice(&nonce); + let plaintext = cipher.decrypt(nonce, Payload::from(ciphertext))?; + Ok(plaintext) +} + +pub fn generate_random_iv() -> [u8; 12] { + OsRng.gen() +} \ No newline at end of file diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index 2815883..4c19649 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -18,6 +18,7 @@ pub mod timers; pub mod magic_link; pub mod auth_tokens; pub mod cursor; +pub mod crypto; pub struct AppState { pub conn: DatabaseConnection @@ -70,6 +71,7 @@ async fn main() -> Result<(), Box> { .service(routes::v1::verify_totp_authenticators::verify_totp_authenticators_request) .service(routes::v1::auth::totp::totp_request) .service(routes::v1::networks::get_networks) + .service(routes::v1::organization::create_org_request) }).bind(CONFIG.server.bind)?.run().await?; Ok(()) diff --git a/trifid-api/src/routes/v1/mod.rs b/trifid-api/src/routes/v1/mod.rs index b619472..248e152 100644 --- a/trifid-api/src/routes/v1/mod.rs +++ b/trifid-api/src/routes/v1/mod.rs @@ -2,4 +2,5 @@ pub mod auth; pub mod signup; pub mod totp_authenticators; pub mod verify_totp_authenticators; -pub mod networks; \ No newline at end of file +pub mod networks; +pub mod organization; \ No newline at end of file diff --git a/trifid-api/src/routes/v1/networks.rs b/trifid-api/src/routes/v1/networks.rs index 8af7f9d..cb7b169 100644 --- a/trifid-api/src/routes/v1/networks.rs +++ b/trifid-api/src/routes/v1/networks.rs @@ -1,7 +1,6 @@ use serde::{Serialize, Deserialize}; use actix_web::{get, HttpRequest, HttpResponse}; use actix_web::web::{Data, Query}; -use chacha20poly1305::consts::P1; use chrono::{TimeZone, Utc}; use log::error; use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder}; @@ -204,23 +203,25 @@ pub async fn get_networks(opts: Query, req_info: HttpReq }); } }; - let models_mapped = models.iter().map(|u| { + let models_mapped: Vec = models.iter().map(|u| { GetNetworksResponseData { id: u.id.clone(), cidr: u.cidr.clone(), organization_id: u.organization.clone(), signing_ca_id: u.signing_ca.clone(), - created_at: Utc.timestamp_opt(u.created_at, 0).unwrap().format("%Y-%m-%dT%H-%M-%S.%.3fZ").to_string(), + created_at: Utc.timestamp_opt(u.created_at, 0).unwrap().format("%Y-%m-%dT%H-%M-%S%.3fZ").to_string(), name: u.name.clone(), lighthouses_as_relays: u.lighthouses_as_relays, } }).collect(); + let count = models_mapped.len() as u64; + HttpResponse::Ok().json(GetNetworksResponse { data: models_mapped, metadata: GetNetworksResponseMetadata { total_count: total, - has_next_page: cursor.page != pages, + has_next_page: cursor.page+1 != pages, has_prev_page: cursor.page != 0, prev_cursor: if cursor.page != 0 { match (Cursor { page: cursor.page - 1 }).try_into() { @@ -230,7 +231,7 @@ pub async fn get_networks(opts: Query, req_info: HttpReq } else { None }, - next_cursor: if cursor.page != pages { + next_cursor: if cursor.page+1 != pages { match (Cursor { page: cursor.page + 1 }).try_into() { Ok(r) => Some(r), Err(_) => None @@ -240,7 +241,7 @@ pub async fn get_networks(opts: Query, req_info: HttpReq }, page: if opts.include_counts { Some(GetNetworksResponseMetadataPage { - count: opts.page_size, + count, start: opts.page_size * cursor.page, }) } else { None }, diff --git a/trifid-api/src/routes/v1/organization.rs b/trifid-api/src/routes/v1/organization.rs new file mode 100644 index 0000000..70f791f --- /dev/null +++ b/trifid-api/src/routes/v1/organization.rs @@ -0,0 +1,262 @@ +// !! !! !! THIS IS NOT A DN-COMPATIBLE API! !! !! !! +// The organization create API has not yet been reverse engineered. This endpoint has nothing to do with the original API +// and is a complete fabrication for trifid. +// Help us out! Reverse engineer the actual org create mechanism and get us back to 100% parity! +// - trifid maintainers + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use actix_web::{HttpRequest, HttpResponse}; +use actix_web::web::{Data, Json}; +use serde::{Serialize, Deserialize}; +use crate::AppState; +use actix_web::post; +use log::error; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter}; +use trifid_pki::cert::{NebulaCertificate, NebulaCertificateDetails, serialize_x25519_private}; +use trifid_pki::ed25519_dalek::SigningKey; +use trifid_pki::rand_core::OsRng; +use trifid_api_entities::entity::{network, organization, signing_ca}; +use crate::auth_tokens::{enforce_2fa, enforce_api_token, TokenInfo}; +use crate::config::CONFIG; +use crate::crypto::{encrypt_with_nonce, generate_random_iv, get_cipher_from_config}; +use crate::error::{APIError, APIErrorsResponse}; +use crate::tokens::random_id; + +#[derive(Serialize, Deserialize)] +pub struct OrgCreateRequest { + pub cidr: String +} + +#[derive(Serialize, Deserialize)] +pub struct OrgCreateResponse { + pub organization: String, + pub ca: String, + pub network: String +} + +#[post("/v1/organization")] +pub async fn create_org_request(req: Json, req_info: HttpRequest, db: Data) -> HttpResponse { + // For this endpoint, you need to be a fully authenticated user + let session_info = enforce_2fa(&req_info, &db.conn).await.unwrap_or(TokenInfo::NotPresent); + + + // we have a session token, which means we have to do a db request to get the organization that this user owns + let user = match session_info { + TokenInfo::AuthToken(tkn) => tkn.session_info.user, + _ => { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: "Unauthorized".to_string(), + path: None, + } + ], + }) + } + }; + + let org = match organization::Entity::find().filter(organization::Column::Owner.eq(&user.id)).one(&db.conn).await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + if org.is_some() { + return HttpResponse::BadRequest().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_USER_ALREADY_OWNS_ORG".to_string(), + message: "This user already owns an organization".to_string(), + path: None, + } + ], + }) + } + + let org = organization::Model { + id: random_id("org"), + name: format!("{}'s Organization", user.email), + owner: user.id.clone(), + }; + + // Generate the CA keypair + let private_key = SigningKey::generate(&mut OsRng); + let public_key = private_key.verifying_key(); + + let mut cert = NebulaCertificate { + details: NebulaCertificateDetails { + name: format!("{} Signing CA", org.name), + ips: vec![], + subnets: vec![], + groups: vec![], + not_before: SystemTime::now(), + not_after: SystemTime::now() + Duration::from_secs(31536000 * 3), // 3 years + public_key: public_key.to_bytes(), + is_ca: true, + issuer: "".to_string(), // Self-signed certificate! No issuer present + }, + signature: vec![], + }; + // Self-sign the CA certificate + match cert.sign(&private_key) { + Ok(_) => (), + Err(e) => { + error!("[security] certificate signature error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_CERT_SIGNING_ERROR".to_string(), + message: "There was an error signing the Certificate Authority on the server. Please try again later.".to_string(), + path: None, + } + ] + }); + } + } + + // PEM-encode the CA key + let ca_key_pem = serialize_x25519_private(&private_key.to_keypair_bytes()); + // PEM-encode the CA cert + let ca_cert_pem = match cert.serialize_to_pem() { + Ok(pem) => pem, + Err(e) => { + error!("[security] certificate encoding error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_CERT_ENCODING_ERROR".to_string(), + message: "There was an error encoding the certificate on the server. Please try again later.".to_string(), + path: None, + } + ] + }); + } + }; + + let iv = generate_random_iv(); // Generate a randomized IV to use for key encryption + let iv_hex = hex::encode(iv); + + let cipher = match get_cipher_from_config(&CONFIG) { + Ok(pem) => pem, + Err(e) => { + error!("[security] cipher fetch error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_CIPHER_ERROR".to_string(), + message: "There was an error encrypting the organization data. Please try again later.".to_string(), + path: None, + } + ] + }); + } + }; + + let ca_key_encrypted = match encrypt_with_nonce(&ca_key_pem, iv, &cipher) { + Ok(key) => hex::encode(key), + Err(e) => { + error!("[security] certificate encoding error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_CERT_ENCODING_ERROR".to_string(), + message: "There was an error encoding the certificate on the server. Please try again later.".to_string(), + path: None, + } + ] + }); + } + }; + + let ca_crt = hex::encode(ca_cert_pem); + + let signing_ca = signing_ca::Model { + id: random_id("ca"), + organization: org.id.clone(), + cert: ca_key_encrypted, + key: ca_crt, + expires: cert.details.not_after.duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() as i64, + nonce: iv_hex, + }; + + let network_model = network::Model { + id: random_id("network"), + cidr: req.cidr.clone(), + organization: org.id.clone(), + signing_ca: signing_ca.id.clone(), + created_at: SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() as i64, + name: "Network1".to_string(), + lighthouses_as_relays: true, + }; + + let new_org_id = org.id.clone(); + let new_signing_ca_id = signing_ca.id.clone(); + let new_network_id = network_model.id.clone(); + + let org_active_model = org.into_active_model(); + let signing_ca_active_model = signing_ca.into_active_model(); + let network_active_model = network_model.into_active_model(); + + match org_active_model.insert(&db.conn).await { + Ok(_) => (), + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + } + match signing_ca_active_model.insert(&db.conn).await { + Ok(_) => (), + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + } + match network_active_model.insert(&db.conn).await { + Ok(_) => (), + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + } + + HttpResponse::Ok().json(OrgCreateResponse { + organization: new_org_id, + ca: new_signing_ca_id, + network: new_network_id, + }) +} \ No newline at end of file