diff --git a/tfweb/src/lib/i18n/en.json b/tfweb/src/lib/i18n/en.json index 7b3d1fd..143e191 100644 --- a/tfweb/src/lib/i18n/en.json +++ b/tfweb/src/lib/i18n/en.json @@ -95,7 +95,16 @@ "grouphelp": "Comma-separated list of groups. This will constrain which groups can be applied to client certs. Default: empty (any)", "apierror": { "orgcreate": "Unable to create organization", - "TypeError: NetworkError when attempting to fetch resource": "Unable to contact the backend. Please try again later." + "cacreate": "Unable to create signing CA", + "TypeError: NetworkError when attempting to fetch resource": "Unable to contact the backend. Please try again later.", + "invaliddata": "The server was unable to parse your request. Reload the page and try again. Ensure all fields are correct." + } + }, + + "admin": { + "apierror": { + "loadownedorg": "Unable to load organizations", + "this user does not have permission to access this org": "You do not have permission to access one of the orgs associated with your account. Your access may have been revoked. Please reload the page." } } } \ No newline at end of file diff --git a/tfweb/src/routes/org/new/+page.svelte b/tfweb/src/routes/org/new/+page.svelte index 137bca5..ac005b7 100644 --- a/tfweb/src/routes/org/new/+page.svelte +++ b/tfweb/src/routes/org/new/+page.svelte @@ -75,27 +75,55 @@ let showAdditionalConstraints = false; + function handle(listfield: string): string[] { + if (listfield == "") { + return [] + } else { + return listfield.split(',') + } + } + async function doCreateFlow() { // STEP ONE: Create the org - logger.info("Creating base organization"); + logger.info("Creating organization"); let created_org_id; + + + try { let resp = await fetch_timeout(`${API_ROOT}/v1/org`, { 'method': 'POST', 'headers': { 'Authorization': 'Bearer ' + api_token - } + }, + 'body': JSON.stringify({ + 'ip_ranges': handle(org_ip_range), + 'subnet_ranges': handle(org_subnets), + 'groups': handle(org_groups) + }) }); - if (resp.code !== 200) { - let err = await resp.json().errors[0].message; + if (!resp.ok) { + if (resp.status === 422) { + // we had an invalid input + logger.error(`Invalid input`); + fullPageError = true; + fullPageErrorTitle = t('neworg.apierror.orgcreate'); + fullPageErrorSubtitle = t('neworg.apierror.invaliddata'); + return; + } + let err = JSON.parse(await resp.text()).errors[0].message; logger.error(`${await resp.json().errors[0]}`); fullPageError = true; fullPageErrorTitle = t('neworg.apierror.orgcreate'); fullPageErrorSubtitle = t('neworg.apierror.' + err); return; } - created_org_id = resp.json().org_id; - logger.info("Able to create base organization with id " + created_org_id); + let resp_objectified = JSON.parse(await resp.text()); + console.log(JSON.stringify(resp_objectified)); + created_org_id = resp_objectified.org_id; + logger.info("Able to create organization with id " + created_org_id); + logger.info("Org create success! Redirecting to manage page"); + window.location.href = "/org/" + created_org_id; } catch (e) { logger.error(`${e}`); fullPageError = true; diff --git a/trifid-api/config.example.toml b/trifid-api/config.example.toml index b60e4c7..921f189 100644 --- a/trifid-api/config.example.toml +++ b/trifid-api/config.example.toml @@ -72,3 +72,10 @@ totp_verification_valid_for = 3600 # Do not change this value in a production instance. It will make existing data inaccessible until changed back. # ------- WARNING ------- data_key = "edd600bcebea461381ea23791b6967c8667e12827ac8b94dc022f189a5dc59a2" + +# How long should CA certs be valid for before they need to be replaced (in seconds)? +# This controls the maximum amount of time a network on this instance can go +# without a rekey. +# You probably don't need to change, this, 31536000 (1 year) is a sane default. +# This value only affects new certs signed by this instance. +ca_certs_valid_for = 31536000 \ No newline at end of file diff --git a/trifid-api/config.toml b/trifid-api/config.toml index 02d1ef3..f206eff 100644 --- a/trifid-api/config.toml +++ b/trifid-api/config.toml @@ -6,3 +6,4 @@ magic_links_valid_for = 86400 session_tokens_valid_for = 86400 totp_verification_valid_for = 3600 data_key = "edd600bcebea461381ea23791b6967c8667e12827ac8b94dc022f189a5dc59a2" +ca_certs_valid_for = 31536000 \ No newline at end of file diff --git a/trifid-api/src/config.rs b/trifid-api/src/config.rs index 0697317..954a4f7 100644 --- a/trifid-api/src/config.rs +++ b/trifid-api/src/config.rs @@ -23,8 +23,9 @@ pub struct TFConfig { pub db_url: String, pub base: Url, pub web_root: Url, - pub magic_links_valid_for: i64, - pub session_tokens_valid_for: i64, - pub totp_verification_valid_for: i64, - pub data_key: String + pub magic_links_valid_for: u64, + pub session_tokens_valid_for: u64, + pub totp_verification_valid_for: u64, + pub data_key: String, + pub ca_certs_valid_for: u64 } \ No newline at end of file diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index 125597a..519617e 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -182,6 +182,8 @@ async fn main() -> Result<(), Box> { crate::routes::v1::user::get_user, crate::routes::v1::user::options, crate::routes::v1::organization::createorgoptions, + crate::routes::v1::ca::get_cas_for_org, + crate::routes::v1::ca::options ]) .register("/", catchers![ crate::routes::handler_400, diff --git a/trifid-api/src/org.rs b/trifid-api/src/org.rs index 83c754e..dc7d723 100644 --- a/trifid-api/src/org.rs +++ b/trifid-api/src/org.rs @@ -15,11 +15,13 @@ // along with this program. If not, see . use std::error::Error; +use log::info; use rocket::form::validate::Contains; use sqlx::PgPool; use trifid_pki::ca::NebulaCAPool; pub async fn get_org_by_owner_id(user: i32, db: &PgPool) -> Result, Box> { + Ok(sqlx::query!("SELECT id FROM organizations WHERE owner = $1", user).fetch_optional(db).await?.map(|r| r.id)) } @@ -60,7 +62,9 @@ pub async fn get_associated_orgs(user: i32, db: &PgPool) -> Result, Box } pub async fn user_has_org_assoc(user: i32, org: i32, db: &PgPool) -> Result> { - Ok(get_associated_orgs(user, db).await?.contains(org)) + let associated_orgs = get_associated_orgs(user, db).await?; + + Ok(associated_orgs.contains(org)) } pub async fn get_org_ca_pool(org: i32, db: &PgPool) -> Result> { diff --git a/trifid-api/src/routes/v1/ca.rs b/trifid-api/src/routes/v1/ca.rs index 6a67c4c..02e3c1c 100644 --- a/trifid-api/src/routes/v1/ca.rs +++ b/trifid-api/src/routes/v1/ca.rs @@ -22,7 +22,7 @@ use sqlx::PgPool; use crate::config::TFConfig; use serde::{Serialize, Deserialize}; use crate::auth::TOTPAuthenticatedUserInfo; -use crate::org::get_org_ca_pool; +use crate::org::{get_associated_orgs, get_org_ca_pool}; #[options("/v1/org/<_id>/ca")] pub fn options(_id: i32) -> &'static str { @@ -45,6 +45,15 @@ pub struct CA { #[get("/v1/org//ca")] pub async fn get_cas_for_org(id: i32, user: TOTPAuthenticatedUserInfo, db: &State) -> Result<(ContentType, Json), (Status, String)> { + let associated_orgs = match get_associated_orgs(user.user_id, db.inner()).await { + Ok(r) => r, + Err(e) => return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_DB_QUERY_FAILED", "an error occurred while running the database query", e))) + }; + + if !associated_orgs.contains(&id) { + return Err((Status::Unauthorized, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_NOT_YOUR_ORG", "you are not authorized to view details of this org"))) + } + let ca_pool = match get_org_ca_pool(id, db.inner()).await { Ok(pool) => pool, Err(e) => { @@ -73,17 +82,4 @@ pub async fn get_cas_for_org(id: i32, user: TOTPAuthenticatedUserInfo, db: &Stat trusted_cas, blocklisted_certs: ca_pool.cert_blocklist, }))) -} - -#[derive(Serialize, Deserialize)] -#[serde(crate = "rocket::serde")] -pub struct CreateCARequest { - pub ip_ranges: Vec, - pub subnet_ranges: Vec, - pub groups: Vec -} - -#[post("/v1/org//ca", data = "")] -pub async fn create_signing_ca_req(id: i32, data: Json, user: TOTPAuthenticatedUserInfo, config: &State, db: &State) { - } \ No newline at end of file diff --git a/trifid-api/src/routes/v1/organization.rs b/trifid-api/src/routes/v1/organization.rs index 2c94363..ade370e 100644 --- a/trifid-api/src/routes/v1/organization.rs +++ b/trifid-api/src/routes/v1/organization.rs @@ -14,11 +14,16 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use rocket::{get, post, options, State}; +use std::time::{Duration, SystemTime}; +use ipnet::Ipv4Net; +use rocket::{get, post, options, State, error}; use rocket::http::{ContentType, Status}; use rocket::serde::json::Json; use serde::{Serialize, Deserialize}; use sqlx::PgPool; +use trifid_pki::cert::{NebulaCertificate, NebulaCertificateDetails, serialize_ed25519_private}; +use trifid_pki::ed25519_dalek::{SigningKey}; +use trifid_pki::rand_core::OsRng; use crate::auth::TOTPAuthenticatedUserInfo; use crate::config::TFConfig; use crate::crypto::{encrypt_with_nonce, generate_random_iv, get_cipher_from_config}; @@ -66,7 +71,7 @@ pub struct OrginfoStruct { #[get("/v1/org/")] pub async fn orginfo_req(orgid: i32, user: TOTPAuthenticatedUserInfo, db: &State) -> Result<(ContentType, Json), (Status, String)> { - if !user_has_org_assoc(orgid, user.user_id, db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))? { + if !user_has_org_assoc(user.user_id, orgid, db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))? { return Err((Status::Unauthorized, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_MISSING_ORG_AUTHORIZATION", "this user does not have permission to access this org"))); } @@ -83,12 +88,59 @@ pub async fn orginfo_req(orgid: i32, user: TOTPAuthenticatedUserInfo, db: &State ))) } -#[post("/v1/org")] -pub async fn create_org(user: TOTPAuthenticatedUserInfo, db: &State, config: &State) -> Result<(ContentType, Json), (Status, String)> { +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct CreateCARequest { + pub ip_ranges: Vec, + pub subnet_ranges: Vec, + pub groups: Vec +} + +#[post("/v1/org", data = "")] +pub async fn create_org(req: Json, user: TOTPAuthenticatedUserInfo, db: &State, config: &State) -> Result<(ContentType, Json), (Status, String)> { if get_org_by_owner_id(user.user_id, db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))?.is_some() { return Err((Status::Conflict, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_USER_OWNS_ORG", "a user can only own one organization at a time"))) } + // Generate the CA keypair + let private_key = SigningKey::generate(&mut OsRng); + let public_key = private_key.verifying_key(); + + // Create the CA certificate + let mut ca_cert = NebulaCertificate { + details: NebulaCertificateDetails { + name: format!("{}'s Organization - Root Signing CA", user.email), + ips: req.ip_ranges.clone(), + subnets: req.subnet_ranges.clone(), + groups: req.groups.clone(), + not_before: SystemTime::now(), + not_after: SystemTime::now() + Duration::from_secs(config.ca_certs_valid_for), + public_key: public_key.to_bytes(), + is_ca: true, + issuer: "".to_string(), // This is a self-signed certificate! There is no issuer present + }, + signature: vec![], + }; + // Self-sign the CA certificate + match ca_cert.sign(&private_key) { + Ok(_) => (), + Err(e) => { + error!("[tfapi] security: certificate signature error: {}", e); + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_CERT_SIGN_ERROR", "there was an error generating the CA certificate, please try again later"))) + } + } + + // PEM-encode the CA key + let ca_key_pem = serialize_ed25519_private(&private_key.to_keypair_bytes()); + // PEM-encode the CA cert + let ca_cert_pem = match ca_cert.serialize_to_pem() { + Ok(pem) => pem, + Err(e) => { + error!("[tfapi] security: certificate encoding error: {}", e); + return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{}\"}}]}}", "ERR_CERT_ENCODE_ERROR", "there was an error encoding the CA certificate, please try again later"))) + } + }; + // generate an AES iv to use for key encryption let iv = generate_random_iv(); let iv_hex = hex::encode(iv); @@ -100,13 +152,13 @@ pub async fn create_org(user: TOTPAuthenticatedUserInfo, db: &State, con return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_CRYPTOGRAPHY_CREATE_CIPHER", "Unable to build cipher construct, please try again later", e))); } }; - let ca_key = match encrypt_with_nonce(b"", iv, &cipher) { + let ca_key = match encrypt_with_nonce(&ca_key_pem, iv, &cipher) { Ok(key) => hex::encode(key), Err(e) => { return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_CRYPTOGRAPHY_ENCRYPT_KEY", "Unable to build cipher construct, please try again later", e))); } }; - let ca_crt = hex::encode(b""); + let ca_crt = hex::encode(ca_cert_pem); let result = sqlx::query!("INSERT INTO organizations (owner, ca_key, ca_crt, iv) VALUES ($1, $2, $3, $4) RETURNING id", owner_id, ca_key, ca_crt, iv_hex).fetch_one(db.inner()).await.map_err(|e| (Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))?; diff --git a/trifid-api/src/tokens.rs b/trifid-api/src/tokens.rs index a9b0d8e..8590cdf 100644 --- a/trifid-api/src/tokens.rs +++ b/trifid-api/src/tokens.rs @@ -74,7 +74,7 @@ pub async fn create_totp_token(email: String, db: &PgPool, config: &TFConfig) -> let otpid = format!("totp-{}", Uuid::new_v4()); - sqlx::query!("INSERT INTO totp_create_tokens (id, expires_on, totp_otpurl, totp_secret) VALUES ($1, $2, $3, $4);", otpid.clone(), (SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64 + config.totp_verification_valid_for) as i32, otpurl, otpsecret).execute(db).await?; + sqlx::query!("INSERT INTO totp_create_tokens (id, expires_on, totp_otpurl, totp_secret) VALUES ($1, $2, $3, $4);", otpid.clone(), (SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + config.totp_verification_valid_for) as i32, otpurl, otpsecret).execute(db).await?; Ok((otpid, totpmachine)) }