diff --git a/.sqlx/query-009fee36733e9527f00a3bae9687ccee65cbf999161b4e1e0b1d7b9ee074d8f5.json b/.sqlx/query-009fee36733e9527f00a3bae9687ccee65cbf999161b4e1e0b1d7b9ee074d8f5.json new file mode 100644 index 0000000..28133c8 --- /dev/null +++ b/.sqlx/query-009fee36733e9527f00a3bae9687ccee65cbf999161b4e1e0b1d7b9ee074d8f5.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\nSELECT name, user_id, alg, pkcs8, created_at\nFROM user_data_certificates_private_keys\nWHERE name = ?1 AND user_id = ?2\n ", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int64" + }, + { + "name": "alg", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "pkcs8", + "ordinal": 3, + "type_info": "Blob" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "009fee36733e9527f00a3bae9687ccee65cbf999161b4e1e0b1d7b9ee074d8f5" +} diff --git a/.sqlx/query-0f7aac057d1a6ea625a3a4b2618b6435c7928c64795f824f0e09372fd1dcf656.json b/.sqlx/query-0f7aac057d1a6ea625a3a4b2618b6435c7928c64795f824f0e09372fd1dcf656.json new file mode 100644 index 0000000..34f51b7 --- /dev/null +++ b/.sqlx/query-0f7aac057d1a6ea625a3a4b2618b6435c7928c64795f824f0e09372fd1dcf656.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\nINSERT INTO user_data_certificates_private_keys (user_id, name, alg, pkcs8, created_at)\nVALUES ( ?1, ?2, ?3, ?4, ?5 )\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "0f7aac057d1a6ea625a3a4b2618b6435c7928c64795f824f0e09372fd1dcf656" +} diff --git a/.sqlx/query-180414b2dc559e21cdfaa145d1f823ec230201a7beb8093f6ad4917209577a0d.json b/.sqlx/query-180414b2dc559e21cdfaa145d1f823ec230201a7beb8093f6ad4917209577a0d.json new file mode 100644 index 0000000..8910f19 --- /dev/null +++ b/.sqlx/query-180414b2dc559e21cdfaa145d1f823ec230201a7beb8093f6ad4917209577a0d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\nDELETE FROM user_data_certificates_private_keys\nWHERE name = ?1 AND user_id = ?2\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "180414b2dc559e21cdfaa145d1f823ec230201a7beb8093f6ad4917209577a0d" +} diff --git a/.sqlx/query-59d3d1721fc3891614ba7cd12c60a9f7fb41c1da6d7665d3daaa83648d68f1c8.json b/.sqlx/query-59d3d1721fc3891614ba7cd12c60a9f7fb41c1da6d7665d3daaa83648d68f1c8.json new file mode 100644 index 0000000..ac5a60d --- /dev/null +++ b/.sqlx/query-59d3d1721fc3891614ba7cd12c60a9f7fb41c1da6d7665d3daaa83648d68f1c8.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\nSELECT name, user_id, alg, x'' as \"pkcs8!\", created_at\nFROM user_data_certificates_private_keys\nWHERE user_id = ?1\nORDER BY created_at\n ", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int64" + }, + { + "name": "alg", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "pkcs8!", + "ordinal": 3, + "type_info": "Blob" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "59d3d1721fc3891614ba7cd12c60a9f7fb41c1da6d7665d3daaa83648d68f1c8" +} diff --git a/.sqlx/query-d3b45aa08b81b8a0b53486e44e9bd25c7110d2a424905082a105c4f48f4da998.json b/.sqlx/query-d3b45aa08b81b8a0b53486e44e9bd25c7110d2a424905082a105c4f48f4da998.json new file mode 100644 index 0000000..4e11051 --- /dev/null +++ b/.sqlx/query-d3b45aa08b81b8a0b53486e44e9bd25c7110d2a424905082a105c4f48f4da998.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\nUPDATE user_data_certificates_private_keys\nSET pkcs8 = ?3\nWHERE user_id = ?1 AND name = ?2\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "d3b45aa08b81b8a0b53486e44e9bd25c7110d2a424905082a105c4f48f4da998" +} diff --git a/Cargo.lock b/Cargo.lock index 3461e18..0ebde37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3770,6 +3770,7 @@ dependencies = [ "serde_with", "sqlx", "tantivy", + "thiserror", "time", "tlsh2", "tokio", diff --git a/Cargo.toml b/Cargo.toml index a4b7887..d43fe8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ serde_repr = "0.1.16" serde_with = "3.3.0" sqlx = "0.7.2" tantivy = "0.21.0" +thiserror = "1.0.49" time = "0.3.30" tlsh2 = "0.3.0" tokio-cron-scheduler = "0.9.4" diff --git a/migrations/20231015195044_certificates_private_keys.sql b/migrations/20231015195044_certificates_private_keys.sql new file mode 100644 index 0000000..f28c36a --- /dev/null +++ b/migrations/20231015195044_certificates_private_keys.sql @@ -0,0 +1,16 @@ +-- Register a new `Private keys` utility under `Digital certificates` and re-order certificate +-- utilities so that `Self-signed certificates` goes after `Private keys`. +UPDATE utils SET id = 11 WHERE id = 5; +INSERT INTO utils (id, handle, name, keywords, parent_id) VALUES + (5, 'certificates__private_keys', 'Private keys', 'private keys openssl encryption pki rsa dsa ec ecdsa curve ed25519 pkcs8 pkcs12 pem', 4); + +-- Create table to store private keys. +CREATE TABLE IF NOT EXISTS user_data_certificates_private_keys +( + name TEXT NOT NULL COLLATE NOCASE, + alg BLOB NOT NULL, + pkcs8 BLOB NOT NULL, + created_at INTEGER NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (name, user_id) +) STRICT; diff --git a/src/error.rs b/src/error.rs index 3390e1a..e3f0804 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,35 +1,249 @@ -use actix_web::{ - http::{header, StatusCode}, - HttpResponse, HttpResponseBuilder, ResponseError, -}; +mod error_kind; + +use actix_web::{http::StatusCode, HttpResponse, HttpResponseBuilder, ResponseError}; +use anyhow::anyhow; +use serde_json::json; use std::fmt::{Debug, Display, Formatter}; -// See examples at https://users.rust-lang.org/t/using-actix-and-anyhow-together/40774. -#[derive(Debug)] -pub struct SecutilsError { - err: anyhow::Error, +pub use error_kind::ErrorKind; + +/// Secutils.dev native error type. +#[derive(thiserror::Error)] +pub struct Error { + root_cause: anyhow::Error, + kind: ErrorKind, +} + +impl Error { + /// Creates a Client error instance with the given root cause. + pub fn client_with_root_cause(root_cause: anyhow::Error) -> Self { + Self { + root_cause, + kind: ErrorKind::ClientError, + } + } + + /// Creates a Client error instance with the given message. + pub fn client(message: M) -> Self + where + M: Display + Debug + Send + Sync + 'static, + { + Self { + root_cause: anyhow!(message), + kind: ErrorKind::ClientError, + } + } + + /// Creates an access forbidden error instance. + pub fn access_forbidden() -> Self { + Self { + root_cause: anyhow!("Access Forbidden"), + kind: ErrorKind::AccessForbidden, + } + } } -impl Display for SecutilsError { +impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - Debug::fmt(self, f) + Debug::fmt(&self.root_cause, f) } } -impl ResponseError for SecutilsError { +impl Debug for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Debug::fmt(&self.root_cause, f) + } +} + +impl ResponseError for Error { + fn status_code(&self) -> StatusCode { + match self.kind { + ErrorKind::ClientError => StatusCode::BAD_REQUEST, + ErrorKind::AccessForbidden => StatusCode::FORBIDDEN, + ErrorKind::Unknown => StatusCode::INTERNAL_SERVER_ERROR, + } + } + fn error_response(&self) -> HttpResponse { - log::error!("Response error: {} {}", self.status_code(), self.err); - HttpResponseBuilder::new(self.status_code()) - .insert_header((header::CONTENT_TYPE, "text/html; charset=utf-8")) - .body(match self.status_code() { - StatusCode::UNAUTHORIZED => "Unauthorized", - _ => "Internal Server Error", - }) + HttpResponseBuilder::new(self.status_code()).json(json!({ + "message": match self.kind { + ErrorKind::ClientError | ErrorKind::AccessForbidden => self.root_cause.to_string(), + ErrorKind::Unknown => "Internal Server Error".to_string(), + } + })) + } +} + +impl From for Error { + fn from(err: anyhow::Error) -> Error { + match err.downcast::() { + Ok(err) => err, + Err(root_cause) => Error { + root_cause, + kind: ErrorKind::Unknown, + }, + } } } -impl From for SecutilsError { - fn from(err: anyhow::Error) -> SecutilsError { - SecutilsError { err } +#[cfg(test)] +mod tests { + use super::{Error, ErrorKind}; + use actix_http::body::MessageBody; + use actix_web::ResponseError; + use anyhow::anyhow; + use bytes::Bytes; + use insta::assert_debug_snapshot; + use reqwest::StatusCode; + + #[test] + fn can_create_client_errors() -> anyhow::Result<()> { + let error = Error::client("Uh oh."); + + assert_eq!(error.kind, ErrorKind::ClientError); + assert_debug_snapshot!(error, @r###""Uh oh.""###); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + + let error_response = error.error_response(); + assert_debug_snapshot!(error_response, @r###" + HttpResponse { + error: None, + res: + Response HTTP/1.1 400 Bad Request + headers: + "content-type": "application/json" + body: Sized(20) + , + } + "###); + let body = error_response.into_body().try_into_bytes().unwrap(); + assert_eq!(body, Bytes::from_static(b"{\"message\":\"Uh oh.\"}")); + + let error = Error::client_with_root_cause(anyhow!("Something sensitive").context("Uh oh.")); + + assert_eq!(error.kind, ErrorKind::ClientError); + assert_debug_snapshot!(error, @r###" + Error { + context: "Uh oh.", + source: "Something sensitive", + } + "###); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + + let error_response = error.error_response(); + assert_debug_snapshot!(error_response, @r###" + HttpResponse { + error: None, + res: + Response HTTP/1.1 400 Bad Request + headers: + "content-type": "application/json" + body: Sized(20) + , + } + "###); + let body = error_response.into_body().try_into_bytes().unwrap(); + assert_eq!(body, Bytes::from_static(b"{\"message\":\"Uh oh.\"}")); + + Ok(()) + } + + #[test] + fn can_create_access_forbidden_errors() -> anyhow::Result<()> { + let error = Error::access_forbidden(); + + assert_eq!(error.kind, ErrorKind::AccessForbidden); + assert_debug_snapshot!(error, @r###""Access Forbidden""###); + + assert_eq!(error.status_code(), StatusCode::FORBIDDEN); + + let error_response = error.error_response(); + assert_debug_snapshot!(error_response, @r###" + HttpResponse { + error: None, + res: + Response HTTP/1.1 403 Forbidden + headers: + "content-type": "application/json" + body: Sized(30) + , + } + "###); + let body = error_response.into_body().try_into_bytes().unwrap(); + assert_eq!( + body, + Bytes::from_static(b"{\"message\":\"Access Forbidden\"}") + ); + + Ok(()) + } + + #[test] + fn can_create_unknown_errors() -> anyhow::Result<()> { + let error = Error::from(anyhow!("Something sensitive")); + + assert_eq!(error.kind, ErrorKind::Unknown); + assert_debug_snapshot!(error, @r###""Something sensitive""###); + + assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + + let error_response = error.error_response(); + assert_debug_snapshot!(error_response, @r###" + HttpResponse { + error: None, + res: + Response HTTP/1.1 500 Internal Server Error + headers: + "content-type": "application/json" + body: Sized(35) + , + } + "###); + let body = error_response.into_body().try_into_bytes().unwrap(); + assert_eq!( + body, + Bytes::from_static(b"{\"message\":\"Internal Server Error\"}") + ); + + Ok(()) + } + + #[test] + fn can_recover_original_error() -> anyhow::Result<()> { + let client_error = + Error::client_with_root_cause(anyhow!("One").context("Two").context("Three")); + let error = Error::from(anyhow!(client_error).context("Four")); + + assert_eq!(error.kind, ErrorKind::ClientError); + assert_debug_snapshot!(error, @r###" + Error { + context: "Three", + source: Error { + context: "Two", + source: "One", + }, + } + "###); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + + let error_response = error.error_response(); + assert_debug_snapshot!(error_response, @r###" + HttpResponse { + error: None, + res: + Response HTTP/1.1 400 Bad Request + headers: + "content-type": "application/json" + body: Sized(19) + , + } + "###); + let body = error_response.into_body().try_into_bytes().unwrap(); + assert_eq!(body, Bytes::from_static(b"{\"message\":\"Three\"}")); + + Ok(()) } } diff --git a/src/error/error_kind.rs b/src/error/error_kind.rs new file mode 100644 index 0000000..3c1e7b3 --- /dev/null +++ b/src/error/error_kind.rs @@ -0,0 +1,10 @@ +/// Describes a Secutils.dev specific error types. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum ErrorKind { + /// Error caused by the error on the client side. + ClientError, + /// Error caused by the lack of privileges to perform an action. + AccessForbidden, + /// Unknown error. + Unknown, +} diff --git a/src/server/handlers/send_message.rs b/src/server/handlers/send_message.rs index f578702..0a61e21 100644 --- a/src/server/handlers/send_message.rs +++ b/src/server/handlers/send_message.rs @@ -1,11 +1,10 @@ use crate::{ - error::SecutilsError, + error::Error as SecutilsError, notifications::{EmailNotificationContent, NotificationContent, NotificationDestination}, server::app_state::AppState, }; use actix_web::{web, HttpResponse}; use serde::Deserialize; -use serde_json::json; use time::OffsetDateTime; #[derive(Deserialize)] @@ -33,7 +32,7 @@ pub async fn send_message( Some(recipient) => recipient, None => { log::error!("SMTP isn't configured."); - return Ok(HttpResponse::InternalServerError().json(json!({ "status": "failed" }))); + return Err(SecutilsError::access_forbidden()); } }; diff --git a/src/server/handlers/status_get.rs b/src/server/handlers/status_get.rs index 1f8f999..32f3fea 100644 --- a/src/server/handlers/status_get.rs +++ b/src/server/handlers/status_get.rs @@ -1,4 +1,4 @@ -use crate::{error::SecutilsError, server::app_state::AppState}; +use crate::{error::Error as SecutilsError, server::app_state::AppState}; use actix_web::{web, HttpResponse}; use anyhow::anyhow; use std::ops::Deref; @@ -8,5 +8,8 @@ pub async fn status_get(state: web::Data) -> Result HttpResponse { - HttpResponse::Unauthorized().json(json!({ - "message": "User is not authorized to perform this action" - })) -} - pub async fn utils_handle_action( state: web::Data, user: Option, @@ -45,35 +38,32 @@ pub async fn utils_handle_action( if let Some(user) = state.api.users().get(user_share.user_id).await? { user } else { - return Ok(unauthorized_response()); + return Err(SecutilsError::access_forbidden()); } } - // Otherwise return "Unauthorized" error. - _ => return Ok(unauthorized_response()), + // Otherwise return "Access forbidden" error. + _ => return Err(SecutilsError::access_forbidden()), }; // Validate action parameters. if let Err(err) = action.validate(&state.api).await { log::error!( - "User ({}) tried to perform invalid utility action: {}", - *user.id, - err + "User ({}) tried to perform invalid utility action: {err:?}", + *user.id ); - return Ok(HttpResponse::BadRequest().json(json!({ "message": err.to_string() }))); + return Err(err.into()); } let user_id = user.id; - action - .handle(user, &state.api) - .await - .map(|response| HttpResponse::Ok().json(response)) - .or_else(|err| { + match action.handle(user, &state.api).await { + Ok(result) => Ok(HttpResponse::Ok().json(result)), + Err(err) => { log::error!( - "User ({}) failed to perform utility action: {}", - *user_id, - err + "User ({}) failed to perform utility action: {err:?}", + *user_id ); - Ok(HttpResponse::InternalServerError().json(json!({ "message": err.to_string() }))) - }) + Err(err.into()) + } + } } diff --git a/src/server/handlers/webhooks_responders.rs b/src/server/handlers/webhooks_responders.rs index 0f2414d..aef863c 100644 --- a/src/server/handlers/webhooks_responders.rs +++ b/src/server/handlers/webhooks_responders.rs @@ -1,4 +1,6 @@ -use crate::{error::SecutilsError, server::app_state::AppState, utils::AutoResponderRequest}; +use crate::{ + error::Error as SecutilsError, server::app_state::AppState, utils::AutoResponderRequest, +}; use actix_http::{body::MessageBody, StatusCode}; use actix_web::{ http::header::{HeaderName, HeaderValue}, diff --git a/src/utils.rs b/src/utils.rs index d7351b2..c84ff77 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -12,9 +12,9 @@ mod webhooks; pub use self::{ certificates::{ - CertificateFormat, EllipticCurve, ExtendedKeyUsage, KeyAlgorithm, KeySize, KeyUsage, - SelfSignedCertificate, SignatureAlgorithm, UtilsCertificatesAction, - UtilsCertificatesActionResult, Version, + CertificatesApi, ExportFormat, ExtendedKeyUsage, KeyUsage, PrivateKey, PrivateKeyAlgorithm, + PrivateKeyEllipticCurve, PrivateKeySize, SelfSignedCertificate, SignatureAlgorithm, + UtilsCertificatesAction, UtilsCertificatesActionResult, Version, }, util::Util, utils_action::UtilsAction, @@ -42,7 +42,7 @@ pub use self::{ #[cfg(test)] pub mod tests { use crate::utils::{ - ExtendedKeyUsage, KeyAlgorithm, KeyUsage, SelfSignedCertificate, SignatureAlgorithm, + ExtendedKeyUsage, KeyUsage, PrivateKeyAlgorithm, SelfSignedCertificate, SignatureAlgorithm, Version, }; use time::OffsetDateTime; @@ -53,7 +53,7 @@ pub mod tests { impl MockSelfSignedCertificate { pub fn new>( name: N, - public_key_algorithm: KeyAlgorithm, + public_key_algorithm: PrivateKeyAlgorithm, signature_algorithm: SignatureAlgorithm, not_valid_before: OffsetDateTime, not_valid_after: OffsetDateTime, diff --git a/src/utils/api_ext.rs b/src/utils/api_ext.rs index 8109096..590fd99 100644 --- a/src/utils/api_ext.rs +++ b/src/utils/api_ext.rs @@ -81,6 +81,15 @@ mod tests { [ Util { id: 5, + handle: "certificates__private_keys", + name: "Private keys", + keywords: Some( + "private keys openssl encryption pki rsa dsa ec ecdsa curve ed25519 pkcs8 pkcs12 pem", + ), + utils: None, + }, + Util { + id: 11, handle: "certificates__self_signed_certificates", name: "Self-signed certificates", keywords: Some( diff --git a/src/utils/certificates.rs b/src/utils/certificates.rs index 6374925..6c28a12 100644 --- a/src/utils/certificates.rs +++ b/src/utils/certificates.rs @@ -1,16 +1,19 @@ -mod certificate_format; +mod database_ext; +mod export_format; +mod private_keys; mod self_signed_certificates; mod utils_certificates_action; mod utils_certificates_action_result; mod x509; +mod api_ext; + pub use self::{ - certificate_format::CertificateFormat, + api_ext::CertificatesApi, + export_format::ExportFormat, + private_keys::{PrivateKey, PrivateKeyAlgorithm, PrivateKeyEllipticCurve, PrivateKeySize}, self_signed_certificates::SelfSignedCertificate, utils_certificates_action::UtilsCertificatesAction, utils_certificates_action_result::UtilsCertificatesActionResult, - x509::{ - EllipticCurve, ExtendedKeyUsage, KeyAlgorithm, KeySize, KeyUsage, SignatureAlgorithm, - Version, - }, + x509::{ExtendedKeyUsage, KeyUsage, SignatureAlgorithm, Version}, }; diff --git a/src/utils/certificates/api_ext.rs b/src/utils/certificates/api_ext.rs new file mode 100644 index 0000000..a47e83b --- /dev/null +++ b/src/utils/certificates/api_ext.rs @@ -0,0 +1,960 @@ +use crate::{ + api::Api, + error::Error as SecutilsError, + network::{DnsResolver, EmailTransport}, + users::{PublicUserDataNamespace, UserId}, + utils::{ + ExportFormat, ExtendedKeyUsage, KeyUsage, PrivateKey, PrivateKeyAlgorithm, + SelfSignedCertificate, SignatureAlgorithm, + }, +}; +use anyhow::{anyhow, bail}; +use openssl::{ + asn1::Asn1Time, + bn::{BigNum, MsbOption}, + dsa::Dsa, + ec::{EcGroup, EcKey}, + error::ErrorStack, + hash::MessageDigest, + nid::Nid, + pkcs12::Pkcs12, + pkey::{PKey, Private}, + rsa::Rsa, + symm::Cipher, + x509::{extension, X509Builder, X509NameBuilder, X509}, +}; +use std::{ + collections::BTreeMap, + io::{Cursor, Write}, +}; +use time::OffsetDateTime; +use zip::{write::FileOptions, CompressionMethod, ZipWriter}; + +/// API extension to work with certificates utilities. +pub struct CertificatesApi<'a, DR: DnsResolver, ET: EmailTransport> { + api: &'a Api, +} + +impl<'a, DR: DnsResolver, ET: EmailTransport> CertificatesApi<'a, DR, ET> { + /// Creates Certificates API. + pub fn new(api: &'a Api) -> Self { + Self { api } + } + + /// Retrieves the private key with the specified name. + pub async fn get_private_key( + &self, + user_id: UserId, + name: &str, + ) -> anyhow::Result> { + self.api + .db + .certificates() + .get_private_key(user_id, name) + .await + } + + /// Generate private key with the specified parameters and stores it in the database. + pub async fn create_private_key( + &self, + user_id: UserId, + name: impl Into, + alg: PrivateKeyAlgorithm, + passphrase: Option<&str>, + ) -> anyhow::Result { + let private_key = PrivateKey { + name: name.into(), + alg, + pkcs8: Self::export_private_key_to_pkcs8(Self::generate_private_key(alg)?, passphrase)?, + // Preserve timestamp only up to seconds. + created_at: OffsetDateTime::from_unix_timestamp( + OffsetDateTime::now_utc().unix_timestamp(), + )?, + }; + + self.api + .db + .certificates() + .insert_private_key(user_id, &private_key) + .await?; + + Ok(private_key) + } + + /// Updates private key passphrase. + pub async fn change_private_key_passphrase( + &self, + user_id: UserId, + name: &str, + passphrase: Option<&str>, + new_passphrase: Option<&str>, + ) -> anyhow::Result<()> { + let Some(private_key) = self.get_private_key(user_id, name).await? else { + bail!(SecutilsError::client(format!( + "Private key ('{name}') is not found." + ))); + }; + + // Try to decrypt private key using the provided passphrase. + let pkcs8_private_key = Self::import_private_key_from_pkcs8(&private_key.pkcs8, passphrase) + .map_err(|err| { + SecutilsError::client_with_root_cause(anyhow!(err).context(format!( + "Unable to decrypt private key ('{name}') with the provided passphrase." + ))) + })?; + + // Convert private key to PKCS8 using the new passphrase, and update it in the database. + self.api + .db + .certificates() + .update_private_key( + user_id, + &PrivateKey { + pkcs8: Self::export_private_key_to_pkcs8(pkcs8_private_key, new_passphrase)?, + ..private_key + }, + ) + .await + } + + /// Removes private key with the specified name. + pub async fn remove_private_key(&self, user_id: UserId, name: &str) -> anyhow::Result<()> { + self.api + .db + .certificates() + .remove_private_key(user_id, name) + .await + } + + /// Exports private key with the specified name to the specified format and passphrase. + pub async fn export_private_key( + &self, + user_id: UserId, + name: &str, + format: ExportFormat, + passphrase: Option<&str>, + export_passphrase: Option<&str>, + ) -> anyhow::Result> { + let Some(private_key) = self.get_private_key(user_id, name).await? else { + bail!(SecutilsError::client(format!( + "Private key ('{name}') is not found." + ))); + }; + + // Try to decrypt private key using the provided passphrase. + let pkcs8_private_key = Self::import_private_key_from_pkcs8(&private_key.pkcs8, passphrase) + .map_err(|err| { + SecutilsError::client_with_root_cause(anyhow!(err).context(format!( + "Unable to decrypt private key ('{name}') with the provided passphrase." + ))) + })?; + + let export_result = match format { + ExportFormat::Pem => { + Self::export_private_key_to_pem(pkcs8_private_key, export_passphrase) + } + ExportFormat::Pkcs8 => { + Self::export_private_key_to_pkcs8(pkcs8_private_key, export_passphrase) + } + ExportFormat::Pkcs12 => Self::export_private_key_to_pkcs12( + &private_key.name, + &pkcs8_private_key, + export_passphrase, + ), + }; + + export_result.map_err(|err| { + SecutilsError::client_with_root_cause(anyhow!(err).context(format!( + "Unable to export private key ('{name}') to the specified format ('{format:?}')." + ))) + .into() + }) + } + + /// Retrieves all private keys that belong to the specified user. + pub async fn get_private_keys(&self, user_id: UserId) -> anyhow::Result> { + self.api.db.certificates().get_private_keys(user_id).await + } + + /// Generates private key and certificate pair. + pub async fn generate_self_signed_certificate( + &self, + user_id: UserId, + template_name: &str, + format: ExportFormat, + passphrase: Option<&str>, + ) -> anyhow::Result> { + // Extract certificate template. + let certificate_template = self + .api + .users() + .get_data::>( + user_id, + PublicUserDataNamespace::SelfSignedCertificates, + ) + .await? + .and_then(|mut map| map.value.remove(template_name)) + .ok_or_else(|| { + SecutilsError::client(format!( + "Certificate template ('{template_name}') is not found." + )) + })?; + + // Create X509 certificate builder pre-filled with the specified template properties. + let mut certificate_builder = Self::create_x509_certificate_builder(&certificate_template)?; + + // Generate private key, set certificate public key and sign it. + let private_key = Self::generate_private_key(certificate_template.key_algorithm)?; + certificate_builder.set_pubkey(&private_key)?; + certificate_builder.sign( + &private_key, + Self::get_message_digest( + certificate_template.key_algorithm, + certificate_template.signature_algorithm, + )?, + )?; + + let certificate = certificate_builder.build(); + Ok(match format { + ExportFormat::Pem => { + Self::export_key_pair_to_pem_archive(certificate, private_key, passphrase)? + } + ExportFormat::Pkcs8 => Self::export_private_key_to_pkcs8(private_key, passphrase)?, + ExportFormat::Pkcs12 => Self::export_key_pair_to_pkcs12( + &certificate_template.name, + &private_key, + &certificate, + passphrase, + )?, + }) + } + + /// Generates private key with the specified parameters. + fn generate_private_key(alg: PrivateKeyAlgorithm) -> anyhow::Result> { + let private_key = match alg { + PrivateKeyAlgorithm::Rsa { key_size } => { + PKey::from_rsa(Rsa::generate(key_size as u32)?)? + } + PrivateKeyAlgorithm::Dsa { key_size } => { + PKey::from_dsa(Dsa::generate(key_size as u32)?)? + } + PrivateKeyAlgorithm::Ecdsa { curve } => { + let ec_group = EcGroup::from_curve_name(Nid::from_raw(curve as i32))?; + PKey::from_ec_key(EcKey::generate(&ec_group)?)? + } + PrivateKeyAlgorithm::Ed25519 => PKey::generate_ed25519()?, + }; + + Ok(private_key) + } + + fn get_message_digest( + pk_alg: PrivateKeyAlgorithm, + sig_alg: SignatureAlgorithm, + ) -> anyhow::Result { + match (pk_alg, sig_alg) { + (PrivateKeyAlgorithm::Rsa { .. }, SignatureAlgorithm::Md5) => Ok(MessageDigest::md5()), + ( + PrivateKeyAlgorithm::Rsa { .. } + | PrivateKeyAlgorithm::Dsa { .. } + | PrivateKeyAlgorithm::Ecdsa { .. }, + SignatureAlgorithm::Sha1, + ) => Ok(MessageDigest::sha1()), + ( + PrivateKeyAlgorithm::Rsa { .. } + | PrivateKeyAlgorithm::Dsa { .. } + | PrivateKeyAlgorithm::Ecdsa { .. }, + SignatureAlgorithm::Sha256, + ) => Ok(MessageDigest::sha256()), + ( + PrivateKeyAlgorithm::Rsa { .. } | PrivateKeyAlgorithm::Ecdsa { .. }, + SignatureAlgorithm::Sha384, + ) => Ok(MessageDigest::sha384()), + ( + PrivateKeyAlgorithm::Rsa { .. } | PrivateKeyAlgorithm::Ecdsa { .. }, + SignatureAlgorithm::Sha512, + ) => Ok(MessageDigest::sha512()), + (PrivateKeyAlgorithm::Ed25519, SignatureAlgorithm::Ed25519) => { + Ok(MessageDigest::null()) + } + _ => Err(anyhow!( + "Public key ({:?}) and signature ({:?}) algorithms are not compatible", + pk_alg, + sig_alg + )), + } + } + + fn export_private_key_to_pem( + private_key: PKey, + passphrase: Option<&str>, + ) -> Result, ErrorStack> { + match passphrase { + None => private_key.private_key_to_pem_pkcs8(), + Some(passphrase) => private_key + .private_key_to_pem_pkcs8_passphrase(Cipher::aes_256_cbc(), passphrase.as_bytes()), + } + } + + fn export_key_pair_to_pem_archive( + certificate: X509, + private_key: PKey, + passphrase: Option<&str>, + ) -> anyhow::Result> { + // 64kb should be more than enough for the certificate + private key. + let mut zip_buffer = [0; 65536]; + let size = { + let mut zip = ZipWriter::new(Cursor::new(&mut zip_buffer[..])); + + let options = FileOptions::default().compression_method(CompressionMethod::Deflated); + zip.start_file("certificate.crt", options)?; + zip.write_all(&certificate.to_pem()?)?; + + zip.start_file("private_key.key", options)?; + zip.write_all(&Self::export_private_key_to_pkcs8(private_key, passphrase)?)?; + + zip.finish()?.position() as usize + }; + + Ok(zip_buffer[..size].to_vec()) + } + + fn export_private_key_to_pkcs8( + private_key: PKey, + passphrase: Option<&str>, + ) -> Result, ErrorStack> { + if let Some(passphrase) = passphrase { + // AEAD ciphers not supported in this command. + private_key + .private_key_to_pkcs8_passphrase(Cipher::aes_256_cbc(), passphrase.as_bytes()) + } else { + private_key.private_key_to_pkcs8() + } + } + + fn export_private_key_to_pkcs12( + name: &str, + private_key: &PKey, + passphrase: Option<&str>, + ) -> Result, ErrorStack> { + Pkcs12::builder() + .name(name) + .pkey(private_key) + .build2(passphrase.unwrap_or_default())? + .to_der() + } + + fn export_key_pair_to_pkcs12( + name: &str, + private_key: &PKey, + certificate: &X509, + passphrase: Option<&str>, + ) -> Result, ErrorStack> { + Pkcs12::builder() + .name(name) + .pkey(private_key) + .cert(certificate) + .build2(passphrase.unwrap_or_default())? + .to_der() + } + + fn import_private_key_from_pkcs8( + pkcs8: &[u8], + passphrase: Option<&str>, + ) -> Result, ErrorStack> { + if let Some(passphrase) = passphrase { + PKey::private_key_from_pkcs8_passphrase(pkcs8, passphrase.as_bytes()) + } else { + PKey::private_key_from_pkcs8(pkcs8) + } + } + + fn create_x509_certificate_builder( + certificate_template: &SelfSignedCertificate, + ) -> anyhow::Result { + let mut x509_name = X509NameBuilder::new()?; + Self::set_x509_name_attribute(&mut x509_name, "CN", &certificate_template.common_name)?; + Self::set_x509_name_attribute(&mut x509_name, "C", &certificate_template.country)?; + Self::set_x509_name_attribute( + &mut x509_name, + "ST", + &certificate_template.state_or_province, + )?; + Self::set_x509_name_attribute(&mut x509_name, "L", &certificate_template.locality)?; + Self::set_x509_name_attribute(&mut x509_name, "O", &certificate_template.organization)?; + Self::set_x509_name_attribute( + &mut x509_name, + "OU", + &certificate_template.organizational_unit, + )?; + let x509_name = x509_name.build(); + + let mut x509 = X509::builder()?; + x509.set_subject_name(&x509_name)?; + x509.set_issuer_name(&x509_name)?; + x509.set_version(certificate_template.version.value())?; + + let mut basic_constraint = extension::BasicConstraints::new(); + if certificate_template.is_ca { + basic_constraint.ca(); + } + x509.append_extension(basic_constraint.critical().build()?)?; + + let serial_number = { + let mut serial = BigNum::new()?; + serial.rand(159, MsbOption::MAYBE_ZERO, false)?; + serial.to_asn1_integer()? + }; + x509.set_serial_number(&serial_number)?; + + let not_before = + Asn1Time::from_unix(certificate_template.not_valid_before.unix_timestamp())?; + x509.set_not_before(¬_before)?; + let not_after = Asn1Time::from_unix(certificate_template.not_valid_after.unix_timestamp())?; + x509.set_not_after(¬_after)?; + + if let Some(ref key_usage) = certificate_template.key_usage { + let mut key_usage_ext = extension::KeyUsage::new(); + + for key_usage in key_usage { + match key_usage { + KeyUsage::DigitalSignature => key_usage_ext.digital_signature(), + KeyUsage::NonRepudiation => key_usage_ext.non_repudiation(), + KeyUsage::KeyEncipherment => key_usage_ext.key_encipherment(), + KeyUsage::DataEncipherment => key_usage_ext.data_encipherment(), + KeyUsage::KeyAgreement => key_usage_ext.key_agreement(), + KeyUsage::KeyCertificateSigning => key_usage_ext.key_cert_sign(), + KeyUsage::CrlSigning => key_usage_ext.crl_sign(), + KeyUsage::EncipherOnly => key_usage_ext.encipher_only(), + KeyUsage::DecipherOnly => key_usage_ext.decipher_only(), + }; + } + + x509.append_extension(key_usage_ext.critical().build()?)?; + } + + if let Some(ref key_usage) = certificate_template.extended_key_usage { + let mut key_usage_ext = extension::ExtendedKeyUsage::new(); + + for key_usage in key_usage { + match key_usage { + ExtendedKeyUsage::TlsWebServerAuthentication => key_usage_ext.server_auth(), + ExtendedKeyUsage::TlsWebClientAuthentication => key_usage_ext.client_auth(), + ExtendedKeyUsage::CodeSigning => key_usage_ext.code_signing(), + ExtendedKeyUsage::EmailProtection => key_usage_ext.email_protection(), + ExtendedKeyUsage::TimeStamping => key_usage_ext.time_stamping(), + }; + } + + x509.append_extension(key_usage_ext.critical().build()?)?; + } + + let subject_key_identifier = + extension::SubjectKeyIdentifier::new().build(&x509.x509v3_context(None, None))?; + x509.append_extension(subject_key_identifier)?; + + Ok(x509) + } + + fn set_x509_name_attribute( + x509_name: &mut X509NameBuilder, + attribute_key: &str, + attribute_value: &Option, + ) -> anyhow::Result<()> { + if attribute_key.is_empty() { + return Ok(()); + } + + if let Some(attribute_value) = attribute_value { + if !attribute_value.is_empty() { + x509_name.append_entry_by_text(attribute_key, attribute_value)?; + } + } + + Ok(()) + } +} + +impl Api { + /// Returns an API to work with certificates utility. + pub fn certificates(&self) -> CertificatesApi { + CertificatesApi::new(self) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + tests::{mock_api, mock_user, MockResolver, MockSelfSignedCertificate}, + users::{DictionaryDataUserDataSetter, PublicUserDataNamespace, UserData}, + utils::{ + CertificatesApi, ExportFormat, PrivateKeyAlgorithm, PrivateKeyEllipticCurve, + PrivateKeySize, SignatureAlgorithm, Version, + }, + }; + use insta::assert_debug_snapshot; + use lettre::transport::stub::AsyncStubTransport; + use openssl::{hash::MessageDigest, pkcs12::Pkcs12}; + use std::collections::BTreeMap; + use time::OffsetDateTime; + + #[actix_rt::test] + async fn can_create_private_key() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let certificates = CertificatesApi::new(&api); + for pass in [Some("pass"), Some(""), None] { + for (alg, bits) in [ + ( + PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024, + }, + 1024, + ), + ( + PrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048, + }, + 2048, + ), + ( + PrivateKeyAlgorithm::Ecdsa { + curve: PrivateKeyEllipticCurve::SECP521R1, + }, + 521, + ), + (PrivateKeyAlgorithm::Ed25519, 256), + ] { + let private_key = certificates + .create_private_key(mock_user.id, format!("pk-{:?}-{:?}", alg, pass), alg, pass) + .await?; + assert_eq!(private_key.alg, alg); + + let imported_key = + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + pass, + )?; + assert_eq!(imported_key.bits(), bits); + } + } + + Ok(()) + } + + #[actix_rt::test] + async fn can_change_private_key_passphrase() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let certificates = CertificatesApi::new(&api); + let private_key = certificates + .create_private_key(mock_user.id, "pk", PrivateKeyAlgorithm::Ed25519, None) + .await?; + + // Decrypting without password should succeed. + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + None, + ) + .is_ok() + ); + + // Set passphrase. + certificates + .change_private_key_passphrase(mock_user.id, "pk", None, Some("pass")) + .await?; + + // Decrypting without passphrase should fail. + let private_key = certificates + .get_private_key(mock_user.id, "pk") + .await? + .unwrap(); + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + None, + ) + .is_err() + ); + // Decrypting with passphrase should succeed. + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + Some("pass"), + ) + .is_ok() + ); + + // Change passphrase. + certificates + .change_private_key_passphrase(mock_user.id, "pk", Some("pass"), Some("pass-1")) + .await?; + + // Decrypting without passphrase should fail. + let private_key = certificates + .get_private_key(mock_user.id, "pk") + .await? + .unwrap(); + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + None, + ) + .is_err() + ); + + // Decrypting with old passphrase should fail. + let private_key = certificates + .get_private_key(mock_user.id, "pk") + .await? + .unwrap(); + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + Some("pass"), + ) + .is_err() + ); + // Decrypting with new passphrase should succeed. + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + Some("pass-1"), + ) + .is_ok() + ); + + // Remove passphrase. + certificates + .change_private_key_passphrase(mock_user.id, "pk", Some("pass-1"), None) + .await?; + + // Decrypting without passphrase should succeed. + let private_key = certificates + .get_private_key(mock_user.id, "pk") + .await? + .unwrap(); + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + None, + ) + .is_ok() + ); + + // Decrypting with old passphrase should fail. + let private_key = certificates + .get_private_key(mock_user.id, "pk") + .await? + .unwrap(); + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + Some("pass"), + ) + .is_err() + ); + // Decrypting with new passphrase should fail. + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &private_key.pkcs8, + Some("pass-1"), + ) + .is_err() + ); + + Ok(()) + } + + #[actix_rt::test] + async fn can_export_private_key() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + // Create private key without passphrase. + let certificates = CertificatesApi::new(&api); + certificates + .create_private_key(mock_user.id, "pk", PrivateKeyAlgorithm::Ed25519, None) + .await?; + + // Export private key without passphrase and make sure it can be without passphrase. + let pkcs8 = certificates + .export_private_key(mock_user.id, "pk", ExportFormat::Pkcs8, None, None) + .await?; + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &pkcs8, None, + ) + .is_ok() + ); + // Export private key with passphrase and make sure it can be imported with passphrase. + let pkcs8 = certificates + .export_private_key(mock_user.id, "pk", ExportFormat::Pkcs8, None, Some("pass")) + .await?; + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &pkcs8, + Some("pass"), + ) + .is_ok() + ); + + // Set passphrase and repeat. + certificates + .change_private_key_passphrase(mock_user.id, "pk", None, Some("pass")) + .await?; + + // Export private key without passphrase and make sure it can be without passphrase. + let pkcs8 = certificates + .export_private_key(mock_user.id, "pk", ExportFormat::Pkcs8, Some("pass"), None) + .await?; + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &pkcs8, None, + ) + .is_ok() + ); + // Export private key with passphrase and make sure it can be imported with passphrase. + let pkcs8 = certificates + .export_private_key( + mock_user.id, + "pk", + ExportFormat::Pkcs8, + Some("pass"), + Some("pass"), + ) + .await?; + assert!( + CertificatesApi::::import_private_key_from_pkcs8( + &pkcs8, + Some("pass"), + ) + .is_ok() + ); + + Ok(()) + } + + #[actix_rt::test] + async fn can_remove_private_key() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let certificates = CertificatesApi::new(&api); + let private_key = certificates + .create_private_key(mock_user.id, "pk", PrivateKeyAlgorithm::Ed25519, None) + .await?; + assert_eq!( + private_key, + certificates + .get_private_key(mock_user.id, "pk") + .await? + .unwrap() + ); + + certificates.remove_private_key(mock_user.id, "pk").await?; + + assert!(certificates + .get_private_key(mock_user.id, "pk") + .await? + .is_none()); + + Ok(()) + } + + #[actix_rt::test] + async fn can_return_multiple_private_keys() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + let certificates = CertificatesApi::new(&api); + assert!(certificates + .get_private_keys(mock_user.id) + .await? + .is_empty()); + + let private_key_one = certificates + .create_private_key(mock_user.id, "pk", PrivateKeyAlgorithm::Ed25519, None) + .await + .map(|mut private_key| { + private_key.pkcs8.clear(); + private_key + })?; + assert_eq!( + certificates.get_private_keys(mock_user.id).await?, + vec![private_key_one.clone()] + ); + + let private_key_two = certificates + .create_private_key(mock_user.id, "pk-2", PrivateKeyAlgorithm::Ed25519, None) + .await + .map(|mut private_key| { + private_key.pkcs8.clear(); + private_key + })?; + assert_eq!( + certificates.get_private_keys(mock_user.id).await?, + vec![private_key_one, private_key_two] + ); + + certificates.remove_private_key(mock_user.id, "pk").await?; + certificates + .remove_private_key(mock_user.id, "pk-2") + .await?; + + assert!(certificates + .get_private_keys(mock_user.id) + .await? + .is_empty()); + + Ok(()) + } + + #[test] + fn picks_correct_message_digest() -> anyhow::Result<()> { + assert!( + CertificatesApi::::get_message_digest( + PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024, + }, + SignatureAlgorithm::Md5 + )? == MessageDigest::md5() + ); + + for pk_algorithm in [ + PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024, + }, + PrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048, + }, + PrivateKeyAlgorithm::Ecdsa { + curve: PrivateKeyEllipticCurve::SECP256R1, + }, + ] { + assert!( + CertificatesApi::::get_message_digest( + pk_algorithm, + SignatureAlgorithm::Sha1 + )? == MessageDigest::sha1() + ); + assert!( + CertificatesApi::::get_message_digest( + pk_algorithm, + SignatureAlgorithm::Sha256 + )? == MessageDigest::sha256() + ); + } + + for pk_algorithm in [ + PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024, + }, + PrivateKeyAlgorithm::Ecdsa { + curve: PrivateKeyEllipticCurve::SECP256R1, + }, + ] { + assert!( + CertificatesApi::::get_message_digest( + pk_algorithm, + SignatureAlgorithm::Sha384 + )? == MessageDigest::sha384() + ); + assert!( + CertificatesApi::::get_message_digest( + pk_algorithm, + SignatureAlgorithm::Sha512 + )? == MessageDigest::sha512() + ); + } + + assert!( + CertificatesApi::::get_message_digest( + PrivateKeyAlgorithm::Ed25519, + SignatureAlgorithm::Ed25519 + )? == MessageDigest::null() + ); + + Ok(()) + } + + #[actix_rt::test] + async fn correctly_generates_x509_certificate() -> anyhow::Result<()> { + let api = mock_api().await?; + + let mock_user = mock_user()?; + api.db.insert_user(&mock_user).await?; + + // January 1, 2000 11:00:00 + let not_valid_before = OffsetDateTime::from_unix_timestamp(946720800)?; + // January 1, 2010 11:00:00 + let not_valid_after = OffsetDateTime::from_unix_timestamp(1262340000)?; + + // Store certificate. + let certificate_template = MockSelfSignedCertificate::new( + "test-1-name", + PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024, + }, + SignatureAlgorithm::Sha256, + not_valid_before, + not_valid_after, + Version::One, + ) + .build(); + DictionaryDataUserDataSetter::upsert( + &api.db, + PublicUserDataNamespace::SelfSignedCertificates, + UserData::new( + mock_user.id, + [( + certificate_template.name.clone(), + Some(certificate_template.clone()), + )] + .into_iter() + .collect::>(), + OffsetDateTime::now_utc(), + ), + ) + .await?; + + let exported_certificate_pair = api + .certificates() + .generate_self_signed_certificate( + mock_user.id, + &certificate_template.name, + ExportFormat::Pkcs12, + None, + ) + .await?; + + let imported_key_pair = Pkcs12::from_der(&exported_certificate_pair)?.parse2("")?; + let private_key = imported_key_pair.pkey.unwrap().rsa()?; + private_key.check_key()?; + assert_eq!(private_key.size(), 128); + + let certificate = imported_key_pair.cert.unwrap(); + assert_debug_snapshot!(certificate.not_before(), @"Jan 1 10:00:00 2000 GMT"); + assert_debug_snapshot!(certificate.not_after(), @"Jan 1 10:00:00 2010 GMT"); + + assert_eq!( + certificate.public_key()?.public_key_to_der()?, + private_key.public_key_to_der()? + ); + + Ok(()) + } +} diff --git a/src/utils/certificates/database_ext.rs b/src/utils/certificates/database_ext.rs new file mode 100644 index 0000000..2d8c65a --- /dev/null +++ b/src/utils/certificates/database_ext.rs @@ -0,0 +1,450 @@ +mod raw_private_key; + +use self::raw_private_key::RawPrivateKey; +use crate::{database::Database, error::Error as SecutilsError, users::UserId, utils::PrivateKey}; +use anyhow::{anyhow, bail}; +use sqlx::{error::ErrorKind as SqlxErrorKind, query, query_as, Pool, Sqlite}; + +/// A database extension for the certificate utility-related operations. +pub struct CertificatesDatabaseExt<'pool> { + pool: &'pool Pool, +} + +impl<'pool> CertificatesDatabaseExt<'pool> { + pub fn new(pool: &'pool Pool) -> Self { + Self { pool } + } + + /// Retrieves private key for the specified user with the specified name. + pub async fn get_private_key( + &self, + user_id: UserId, + name: &str, + ) -> anyhow::Result> { + query_as!( + RawPrivateKey, + r#" +SELECT name, user_id, alg, pkcs8, created_at +FROM user_data_certificates_private_keys +WHERE name = ?1 AND user_id = ?2 + "#, + name, + *user_id + ) + .fetch_optional(self.pool) + .await? + .map(PrivateKey::try_from) + .transpose() + } + + /// Inserts private key. + pub async fn insert_private_key( + &self, + user_id: UserId, + private_key: &PrivateKey, + ) -> anyhow::Result<()> { + let raw_private_key = RawPrivateKey::try_from((user_id, private_key))?; + let result = query!( + r#" +INSERT INTO user_data_certificates_private_keys (user_id, name, alg, pkcs8, created_at) +VALUES ( ?1, ?2, ?3, ?4, ?5 ) + "#, + raw_private_key.user_id, + raw_private_key.name, + raw_private_key.alg, + raw_private_key.pkcs8, + raw_private_key.created_at + ) + .execute(self.pool) + .await; + + if let Err(err) = result { + let is_conflict_error = err + .as_database_error() + .map(|db_error| matches!(db_error.kind(), SqlxErrorKind::UniqueViolation)) + .unwrap_or_default(); + bail!(if is_conflict_error { + SecutilsError::client_with_root_cause(anyhow!(err).context(format!( + "Private key ('{}') already exists.", + private_key.name + ))) + } else { + SecutilsError::from(anyhow!(err).context(format!( + "Couldn't create private key ('{}') due to unknown reason.", + private_key.name + ))) + }); + } + + Ok(()) + } + + /// Upserts private key (only `pkcs8` content can be updated due to password change). + pub async fn update_private_key( + &self, + user_id: UserId, + private_key: &PrivateKey, + ) -> anyhow::Result<()> { + let raw_private_key = RawPrivateKey::try_from((user_id, private_key))?; + let result = query!( + r#" +UPDATE user_data_certificates_private_keys +SET pkcs8 = ?3 +WHERE user_id = ?1 AND name = ?2 + "#, + raw_private_key.user_id, + raw_private_key.name, + raw_private_key.pkcs8 + ) + .execute(self.pool) + .await?; + + if result.rows_affected() == 0 { + bail!(SecutilsError::client(format!( + "A private key ('{}') doesn't exist.", + private_key.name + ))); + } + + Ok(()) + } + + /// Removes private key for the specified user with the specified name. + pub async fn remove_private_key(&self, user_id: UserId, name: &str) -> anyhow::Result<()> { + query!( + r#" +DELETE FROM user_data_certificates_private_keys +WHERE name = ?1 AND user_id = ?2 + "#, + name, + *user_id + ) + .execute(self.pool) + .await?; + + Ok(()) + } + + /// Retrieves all private keys for the specified user. + pub async fn get_private_keys(&self, user_id: UserId) -> anyhow::Result> { + // When returning data about all private keys, we don't return the pkcs8 data itself since + // it's supposed to be retrieved only one by one. + let raw_private_keys = query_as!( + RawPrivateKey, + r#" +SELECT name, user_id, alg, x'' as "pkcs8!", created_at +FROM user_data_certificates_private_keys +WHERE user_id = ?1 +ORDER BY created_at + "#, + *user_id + ) + .fetch_all(self.pool) + .await?; + + let mut private_keys = vec![]; + for raw_private_key in raw_private_keys { + private_keys.push(PrivateKey::try_from(raw_private_key)?); + } + + Ok(private_keys) + } +} + +impl Database { + /// Returns a database extension for the certificate utility-related operations. + pub fn certificates(&self) -> CertificatesDatabaseExt { + CertificatesDatabaseExt::new(&self.pool) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + error::Error as SecutilsError, + tests::{mock_db, mock_user}, + utils::{PrivateKey, PrivateKeyAlgorithm, PrivateKeySize}, + }; + use actix_web::ResponseError; + use insta::assert_debug_snapshot; + use time::OffsetDateTime; + + #[actix_rt::test] + async fn can_add_and_retrieve_private_keys() -> anyhow::Result<()> { + let user = mock_user()?; + let db = mock_db().await?; + db.insert_user(&user).await?; + + let mut private_keys = vec![ + PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048, + }, + pkcs8: vec![1, 2, 3], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }, + PrivateKey { + name: "pk-name-2".to_string(), + alg: PrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048, + }, + pkcs8: vec![4, 5, 6], + created_at: OffsetDateTime::from_unix_timestamp(946820800)?, + }, + ]; + + for private_key in private_keys.iter() { + db.certificates() + .insert_private_key(user.id, private_key) + .await?; + } + + let private_key = db + .certificates() + .get_private_key(user.id, "pk-name") + .await? + .unwrap(); + assert_eq!(private_key, private_keys.remove(0)); + + let private_key = db + .certificates() + .get_private_key(user.id, "pk-name-2") + .await? + .unwrap(); + assert_eq!(private_key, private_keys.remove(0)); + + assert!(db + .certificates() + .get_private_key(user.id, "pk-name-3") + .await? + .is_none()); + + Ok(()) + } + + #[actix_rt::test] + async fn correctly_handles_duplicated_private_keys() -> anyhow::Result<()> { + let user = mock_user()?; + let db = mock_db().await?; + db.insert_user(&user).await?; + + let private_key = PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048, + }, + pkcs8: vec![1, 2, 3], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }; + + db.certificates() + .insert_private_key(user.id, &private_key) + .await?; + + let insert_error = db + .certificates() + .insert_private_key(user.id, &private_key) + .await + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!(insert_error.status_code(), 400); + assert_debug_snapshot!( + insert_error, + @r###" + Error { + context: "Private key (\'pk-name\') already exists.", + source: Database( + SqliteError { + code: 1555, + message: "UNIQUE constraint failed: user_data_certificates_private_keys.name, user_data_certificates_private_keys.user_id", + }, + ), + } + "### + ); + + Ok(()) + } + + #[actix_rt::test] + async fn can_update_private_key_content() -> anyhow::Result<()> { + let user = mock_user()?; + let db = mock_db().await?; + db.insert_user(&user).await?; + + db.certificates() + .insert_private_key( + user.id, + &PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048, + }, + pkcs8: vec![1, 2, 3], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }, + ) + .await?; + + db.certificates() + .update_private_key( + user.id, + &PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024, + }, + pkcs8: vec![4, 5, 6], + created_at: OffsetDateTime::from_unix_timestamp(956720800)?, + }, + ) + .await?; + + let private_key = db + .certificates() + .get_private_key(user.id, "pk-name") + .await? + .unwrap(); + assert_eq!( + private_key, + PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048, + }, + pkcs8: vec![4, 5, 6], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + } + ); + + Ok(()) + } + + #[actix_rt::test] + async fn can_remove_private_keys() -> anyhow::Result<()> { + let user = mock_user()?; + let db = mock_db().await?; + db.insert_user(&user).await?; + + let mut private_keys = vec![ + PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048, + }, + pkcs8: vec![1, 2, 3], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }, + PrivateKey { + name: "pk-name-2".to_string(), + alg: PrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048, + }, + pkcs8: vec![4, 5, 6], + created_at: OffsetDateTime::from_unix_timestamp(946820800)?, + }, + ]; + + for private_key in private_keys.iter() { + db.certificates() + .insert_private_key(user.id, private_key) + .await?; + } + + let private_key = db + .certificates() + .get_private_key(user.id, "pk-name") + .await? + .unwrap(); + assert_eq!(private_key, private_keys.remove(0)); + + let private_key = db + .certificates() + .get_private_key(user.id, "pk-name-2") + .await? + .unwrap(); + assert_eq!(private_key, private_keys[0].clone()); + + db.certificates() + .remove_private_key(user.id, "pk-name") + .await?; + + let private_key = db + .certificates() + .get_private_key(user.id, "pk-name") + .await?; + assert!(private_key.is_none()); + + let private_key = db + .certificates() + .get_private_key(user.id, "pk-name-2") + .await? + .unwrap(); + assert_eq!(private_key, private_keys.remove(0)); + + db.certificates() + .remove_private_key(user.id, "pk-name-2") + .await?; + + let private_key = db + .certificates() + .get_private_key(user.id, "pk-name") + .await?; + assert!(private_key.is_none()); + + let private_key = db + .certificates() + .get_private_key(user.id, "pk-name-2") + .await?; + assert!(private_key.is_none()); + + Ok(()) + } + + #[actix_rt::test] + async fn can_retrieve_all_private_keys() -> anyhow::Result<()> { + let user = mock_user()?; + let db = mock_db().await?; + db.insert_user(&user).await?; + + let private_keys = vec![ + PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048, + }, + pkcs8: vec![1, 2, 3], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }, + PrivateKey { + name: "pk-name-2".to_string(), + alg: PrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048, + }, + pkcs8: vec![4, 5, 6], + created_at: OffsetDateTime::from_unix_timestamp(946820800)?, + }, + ]; + + for private_key in private_keys.iter() { + db.certificates() + .insert_private_key(user.id, private_key) + .await?; + } + + assert_eq!( + db.certificates().get_private_keys(user.id).await?, + private_keys + .into_iter() + .map(|mut private_key| { + private_key.pkcs8.clear(); + private_key + }) + .collect::>() + ); + + Ok(()) + } +} diff --git a/src/utils/certificates/database_ext/raw_private_key.rs b/src/utils/certificates/database_ext/raw_private_key.rs new file mode 100644 index 0000000..d7e5ffc --- /dev/null +++ b/src/utils/certificates/database_ext/raw_private_key.rs @@ -0,0 +1,206 @@ +use crate::{ + users::UserId, + utils::{PrivateKey, PrivateKeyAlgorithm, PrivateKeyEllipticCurve, PrivateKeySize}, +}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +/// Main `KeyAlgorithm` enum has Serde attributes that are needed fro JSON serialization, but aren't +/// compatible with the `postcard`. +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)] +enum RawPrivateKeyAlgorithm { + Rsa { key_size: PrivateKeySize }, + Dsa { key_size: PrivateKeySize }, + Ecdsa { curve: PrivateKeyEllipticCurve }, + Ed25519, +} + +impl From for PrivateKeyAlgorithm { + fn from(raw: RawPrivateKeyAlgorithm) -> Self { + match raw { + RawPrivateKeyAlgorithm::Rsa { key_size } => PrivateKeyAlgorithm::Rsa { key_size }, + RawPrivateKeyAlgorithm::Dsa { key_size } => PrivateKeyAlgorithm::Dsa { key_size }, + RawPrivateKeyAlgorithm::Ecdsa { curve } => PrivateKeyAlgorithm::Ecdsa { curve }, + RawPrivateKeyAlgorithm::Ed25519 => PrivateKeyAlgorithm::Ed25519, + } + } +} + +impl From for RawPrivateKeyAlgorithm { + fn from(item: PrivateKeyAlgorithm) -> Self { + match item { + PrivateKeyAlgorithm::Rsa { key_size } => RawPrivateKeyAlgorithm::Rsa { key_size }, + PrivateKeyAlgorithm::Dsa { key_size } => RawPrivateKeyAlgorithm::Dsa { key_size }, + PrivateKeyAlgorithm::Ecdsa { curve } => RawPrivateKeyAlgorithm::Ecdsa { curve }, + PrivateKeyAlgorithm::Ed25519 => RawPrivateKeyAlgorithm::Ed25519, + } + } +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub(super) struct RawPrivateKey { + pub name: String, + pub user_id: i64, + pub alg: Vec, + pub pkcs8: Vec, + pub created_at: i64, +} + +impl TryFrom for PrivateKey { + type Error = anyhow::Error; + + fn try_from(raw: RawPrivateKey) -> Result { + Ok(PrivateKey { + name: raw.name, + alg: postcard::from_bytes::(&raw.alg)?.into(), + pkcs8: raw.pkcs8, + created_at: OffsetDateTime::from_unix_timestamp(raw.created_at)?, + }) + } +} + +impl TryFrom<(UserId, &PrivateKey)> for RawPrivateKey { + type Error = anyhow::Error; + + fn try_from((user_id, item): (UserId, &PrivateKey)) -> Result { + Ok(RawPrivateKey { + name: item.name.clone(), + user_id: *user_id, + alg: postcard::to_stdvec(&RawPrivateKeyAlgorithm::from(item.alg))?, + pkcs8: item.pkcs8.clone(), + created_at: item.created_at.unix_timestamp(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{RawPrivateKey, RawPrivateKeyAlgorithm}; + use crate::utils::{PrivateKey, PrivateKeyAlgorithm, PrivateKeyEllipticCurve, PrivateKeySize}; + use time::OffsetDateTime; + + #[test] + fn can_convert_to_key_algorithm() -> anyhow::Result<()> { + assert_eq!( + PrivateKeyAlgorithm::from(RawPrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048 + }), + PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048 + } + ); + + assert_eq!( + PrivateKeyAlgorithm::from(RawPrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048 + }), + PrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048 + } + ); + + assert_eq!( + PrivateKeyAlgorithm::from(RawPrivateKeyAlgorithm::Ecdsa { + curve: PrivateKeyEllipticCurve::SECP256R1 + }), + PrivateKeyAlgorithm::Ecdsa { + curve: PrivateKeyEllipticCurve::SECP256R1 + } + ); + + assert_eq!( + PrivateKeyAlgorithm::from(RawPrivateKeyAlgorithm::Ed25519), + PrivateKeyAlgorithm::Ed25519 + ); + + Ok(()) + } + + #[test] + fn can_convert_to_raw_key_algorithm() -> anyhow::Result<()> { + assert_eq!( + RawPrivateKeyAlgorithm::from(PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048 + }), + RawPrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048 + } + ); + + assert_eq!( + RawPrivateKeyAlgorithm::from(PrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048 + }), + RawPrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048 + } + ); + + assert_eq!( + RawPrivateKeyAlgorithm::from(PrivateKeyAlgorithm::Ecdsa { + curve: PrivateKeyEllipticCurve::SECP256R1 + }), + RawPrivateKeyAlgorithm::Ecdsa { + curve: PrivateKeyEllipticCurve::SECP256R1 + } + ); + + assert_eq!( + RawPrivateKeyAlgorithm::from(PrivateKeyAlgorithm::Ed25519), + RawPrivateKeyAlgorithm::Ed25519 + ); + + Ok(()) + } + + #[test] + fn can_convert_into_private_key() -> anyhow::Result<()> { + assert_eq!( + PrivateKey::try_from(RawPrivateKey { + name: "pk-name".to_string(), + user_id: 1, + alg: vec![0, 1], + pkcs8: vec![1, 2, 3], + // January 1, 2000 10:00:00 + created_at: 946720800, + })?, + PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048 + }, + pkcs8: vec![1, 2, 3], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + } + ); + + Ok(()) + } + + #[test] + fn can_convert_into_raw_private_key() -> anyhow::Result<()> { + assert_eq!( + RawPrivateKey::try_from(( + 1.try_into()?, + &PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048 + }, + pkcs8: vec![1, 2, 3], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + } + ))?, + RawPrivateKey { + name: "pk-name".to_string(), + user_id: 1, + alg: vec![0, 1], + pkcs8: vec![1, 2, 3], + // January 1, 2000 10:00:00 + created_at: 946720800, + } + ); + + Ok(()) + } +} diff --git a/src/utils/certificates/certificate_format.rs b/src/utils/certificates/export_format.rs similarity index 77% rename from src/utils/certificates/certificate_format.rs rename to src/utils/certificates/export_format.rs index 0098046..fed6c6e 100644 --- a/src/utils/certificates/certificate_format.rs +++ b/src/utils/certificates/export_format.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// Defines a format to use for the generated certificate(s) and keys. #[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq)] #[serde(rename_all = "lowercase")] -pub enum CertificateFormat { +pub enum ExportFormat { /// The PEM format is the most common format that Certificate Authorities issue certificates in. /// PEM certificates usually have extensions such as ".pem", ".crt", ".cer", and ".key". They /// are Base64 encoded ASCII files and contain "-----BEGIN CERTIFICATE-----" and @@ -28,14 +28,14 @@ pub enum CertificateFormat { #[cfg(test)] mod tests { - use crate::utils::CertificateFormat; + use crate::utils::ExportFormat; use insta::assert_json_snapshot; #[test] fn serialization() -> anyhow::Result<()> { - assert_json_snapshot!(CertificateFormat::Pem, @r###""pem""###); - assert_json_snapshot!(CertificateFormat::Pkcs8, @r###""pkcs8""###); - assert_json_snapshot!(CertificateFormat::Pkcs12, @r###""pkcs12""###); + assert_json_snapshot!(ExportFormat::Pem, @r###""pem""###); + assert_json_snapshot!(ExportFormat::Pkcs8, @r###""pkcs8""###); + assert_json_snapshot!(ExportFormat::Pkcs12, @r###""pkcs12""###); Ok(()) } @@ -43,16 +43,16 @@ mod tests { #[test] fn deserialization() -> anyhow::Result<()> { assert_eq!( - serde_json::from_str::(r#""pem""#)?, - CertificateFormat::Pem + serde_json::from_str::(r#""pem""#)?, + ExportFormat::Pem ); assert_eq!( - serde_json::from_str::(r#""pkcs8""#)?, - CertificateFormat::Pkcs8 + serde_json::from_str::(r#""pkcs8""#)?, + ExportFormat::Pkcs8 ); assert_eq!( - serde_json::from_str::(r#""pkcs12""#)?, - CertificateFormat::Pkcs12 + serde_json::from_str::(r#""pkcs12""#)?, + ExportFormat::Pkcs12 ); Ok(()) diff --git a/src/utils/certificates/private_keys.rs b/src/utils/certificates/private_keys.rs new file mode 100644 index 0000000..b11ef76 --- /dev/null +++ b/src/utils/certificates/private_keys.rs @@ -0,0 +1,10 @@ +mod private_key; +mod private_key_algorithm; +mod private_key_elliptic_curve; + +mod private_key_size; + +pub use self::{ + private_key::PrivateKey, private_key_algorithm::PrivateKeyAlgorithm, + private_key_elliptic_curve::PrivateKeyEllipticCurve, private_key_size::PrivateKeySize, +}; diff --git a/src/utils/certificates/private_keys/private_key.rs b/src/utils/certificates/private_keys/private_key.rs new file mode 100644 index 0000000..e817391 --- /dev/null +++ b/src/utils/certificates/private_keys/private_key.rs @@ -0,0 +1,89 @@ +use crate::utils::PrivateKeyAlgorithm; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +/// Describes stored private key. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PrivateKey { + /// Arbitrary name of the private key. + pub name: String, + /// Algorithm of the private key (RSA, DSA, etc.). + pub alg: PrivateKeyAlgorithm, + /// Private key serialized to PKCS#8 format (with or without encryption). + pub pkcs8: Vec, + /// Date and time when the private key was created. + #[serde(with = "time::serde::timestamp")] + pub created_at: OffsetDateTime, +} + +#[cfg(test)] +mod tests { + use crate::utils::{PrivateKey, PrivateKeyAlgorithm, PrivateKeySize}; + use insta::assert_json_snapshot; + use time::OffsetDateTime; + + #[test] + fn serialization() -> anyhow::Result<()> { + assert_json_snapshot!( + PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { key_size: PrivateKeySize::Size2048 }, + pkcs8: vec![1, 2, 3], + // January 1, 2000 11:00:00 + created_at: OffsetDateTime::from_unix_timestamp(946720800)? + }, + @r###" + { + "name": "pk-name", + "alg": { + "keyType": "rsa", + "keySize": "2048" + }, + "pkcs8": [ + 1, + 2, + 3 + ], + "createdAt": 946720800 + } + "### + ); + + Ok(()) + } + + #[test] + fn deserialization() -> anyhow::Result<()> { + assert_eq!( + serde_json::from_str::( + r#" + { + "name": "pk-name", + "alg": { + "keyType": "rsa", + "keySize": "2048" + }, + "pkcs8": [ + 1, + 2, + 3 + ], + "createdAt": 946720800 + } + "# + )?, + PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048 + }, + pkcs8: vec![1, 2, 3], + // January 1, 2000 11:00:00 + created_at: OffsetDateTime::from_unix_timestamp(946720800)? + }, + ); + + Ok(()) + } +} diff --git a/src/utils/certificates/private_keys/private_key_algorithm.rs b/src/utils/certificates/private_keys/private_key_algorithm.rs new file mode 100644 index 0000000..3dea695 --- /dev/null +++ b/src/utils/certificates/private_keys/private_key_algorithm.rs @@ -0,0 +1,89 @@ +use crate::utils::{PrivateKeyEllipticCurve, PrivateKeySize}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "keyType")] +pub enum PrivateKeyAlgorithm { + #[serde(rename_all = "camelCase")] + Rsa { + key_size: PrivateKeySize, + }, + #[serde(rename_all = "camelCase")] + Dsa { + key_size: PrivateKeySize, + }, + Ecdsa { + curve: PrivateKeyEllipticCurve, + }, + Ed25519, +} + +#[cfg(test)] +mod tests { + use crate::utils::{PrivateKeyAlgorithm, PrivateKeyEllipticCurve, PrivateKeySize}; + use insta::assert_json_snapshot; + + #[test] + fn serialization() -> anyhow::Result<()> { + assert_json_snapshot!(PrivateKeyAlgorithm::Rsa { key_size: PrivateKeySize::Size1024 }, @r###" + { + "keyType": "rsa", + "keySize": "1024" + } + "###); + assert_json_snapshot!(PrivateKeyAlgorithm::Dsa { key_size: PrivateKeySize::Size2048 }, @r###" + { + "keyType": "dsa", + "keySize": "2048" + } + "###); + assert_json_snapshot!(PrivateKeyAlgorithm::Ecdsa { curve: PrivateKeyEllipticCurve::SECP256R1 }, @r###" + { + "keyType": "ecdsa", + "curve": "secp256r1" + } + "###); + assert_json_snapshot!(PrivateKeyAlgorithm::Ed25519, @r###" + { + "keyType": "ed25519" + } + "###); + + Ok(()) + } + + #[test] + fn deserialization() -> anyhow::Result<()> { + assert_eq!( + serde_json::from_str::( + r#"{ "keyType": "rsa", "keySize": "1024" }"# + )?, + PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024 + } + ); + assert_eq!( + serde_json::from_str::( + r#"{ "keyType": "dsa", "keySize": "2048" }"# + )?, + PrivateKeyAlgorithm::Dsa { + key_size: PrivateKeySize::Size2048 + } + ); + assert_eq!( + serde_json::from_str::( + r#"{ "keyType": "ecdsa", "curve": "secp256r1" }"# + )?, + PrivateKeyAlgorithm::Ecdsa { + curve: PrivateKeyEllipticCurve::SECP256R1 + } + ); + assert_eq!( + serde_json::from_str::(r#"{ "keyType": "ed25519" }"#)?, + PrivateKeyAlgorithm::Ed25519 + ); + + Ok(()) + } +} diff --git a/src/utils/certificates/private_keys/private_key_elliptic_curve.rs b/src/utils/certificates/private_keys/private_key_elliptic_curve.rs new file mode 100644 index 0000000..f7886e1 --- /dev/null +++ b/src/utils/certificates/private_keys/private_key_elliptic_curve.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; + +/// Defines named elliptic curves used with Elliptic Curve Digital Signature Algorithm (ECDSA). +/// See https://www.rfc-editor.org/rfc/rfc8422.html#appendix-A. +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum PrivateKeyEllipticCurve { + /// Elliptic curve prime256v1 (ANSI X9.62) / secp256r1 (SECG) / NIST P-256 (NIST). + SECP256R1 = 415, + /// Elliptic curve secp384r1 (SECG) / NIST P-384 (NIST). + SECP384R1 = 715, + /// Elliptic curve secp521r1 (SECG) / NIST P-521 (NIST). + SECP521R1 = 716, +} + +#[cfg(test)] +mod tests { + use crate::utils::PrivateKeyEllipticCurve; + use insta::assert_json_snapshot; + + #[test] + fn serialization() { + assert_json_snapshot!(PrivateKeyEllipticCurve::SECP256R1, @r###""secp256r1""###); + assert_json_snapshot!(PrivateKeyEllipticCurve::SECP384R1, @r###""secp384r1""###); + assert_json_snapshot!(PrivateKeyEllipticCurve::SECP521R1, @r###""secp521r1""###); + } + + #[test] + fn deserialization() -> anyhow::Result<()> { + assert_eq!( + serde_json::from_str::(r#""secp256r1""#)?, + PrivateKeyEllipticCurve::SECP256R1 + ); + + assert_eq!( + serde_json::from_str::(r#""secp384r1""#)?, + PrivateKeyEllipticCurve::SECP384R1 + ); + + assert_eq!( + serde_json::from_str::(r#""secp521r1""#)?, + PrivateKeyEllipticCurve::SECP521R1 + ); + + Ok(()) + } + + #[test] + fn as_number() { + assert_eq!(PrivateKeyEllipticCurve::SECP256R1 as u32, 415); + assert_eq!(PrivateKeyEllipticCurve::SECP384R1 as u32, 715); + assert_eq!(PrivateKeyEllipticCurve::SECP521R1 as u32, 716); + } +} diff --git a/src/utils/certificates/private_keys/private_key_size.rs b/src/utils/certificates/private_keys/private_key_size.rs new file mode 100644 index 0000000..8271732 --- /dev/null +++ b/src/utils/certificates/private_keys/private_key_size.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; + +/// The key size defines a number of bits in a key used by a cryptographic algorithm. +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub enum PrivateKeySize { + #[serde(rename = "1024")] + Size1024 = 1024, + #[serde(rename = "2048")] + Size2048 = 2048, + #[serde(rename = "4096")] + Size4096 = 4096, + #[serde(rename = "8192")] + Size8192 = 8192, +} + +#[cfg(test)] +mod tests { + use crate::utils::PrivateKeySize; + use insta::assert_json_snapshot; + + #[test] + fn serialization() { + assert_json_snapshot!(PrivateKeySize::Size1024, @r###""1024""###); + assert_json_snapshot!(PrivateKeySize::Size2048, @r###""2048""###); + assert_json_snapshot!(PrivateKeySize::Size4096, @r###""4096""###); + assert_json_snapshot!(PrivateKeySize::Size8192, @r###""8192""###); + } + + #[test] + fn deserialization() -> anyhow::Result<()> { + assert_eq!( + serde_json::from_str::(r#""1024""#)?, + PrivateKeySize::Size1024 + ); + + assert_eq!( + serde_json::from_str::(r#""2048""#)?, + PrivateKeySize::Size2048 + ); + + assert_eq!( + serde_json::from_str::(r#""4096""#)?, + PrivateKeySize::Size4096 + ); + + assert_eq!( + serde_json::from_str::(r#""8192""#)?, + PrivateKeySize::Size8192 + ); + + Ok(()) + } + + #[test] + fn as_number() { + assert_eq!(PrivateKeySize::Size1024 as u32, 1024); + assert_eq!(PrivateKeySize::Size2048 as u32, 2048); + assert_eq!(PrivateKeySize::Size4096 as u32, 4096); + assert_eq!(PrivateKeySize::Size8192 as u32, 8192); + } +} diff --git a/src/utils/certificates/self_signed_certificates/self_signed_certificate.rs b/src/utils/certificates/self_signed_certificates/self_signed_certificate.rs index 701584a..b19e3dd 100644 --- a/src/utils/certificates/self_signed_certificates/self_signed_certificate.rs +++ b/src/utils/certificates/self_signed_certificates/self_signed_certificate.rs @@ -1,6 +1,6 @@ use crate::utils::{ certificates::{ExtendedKeyUsage, KeyUsage, Version}, - KeyAlgorithm, SignatureAlgorithm, + PrivateKeyAlgorithm, SignatureAlgorithm, }; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -24,7 +24,7 @@ pub struct SelfSignedCertificate { #[serde(rename = "ou", skip_serializing_if = "Option::is_none")] pub organizational_unit: Option, #[serde(rename = "ka")] - pub key_algorithm: KeyAlgorithm, + pub key_algorithm: PrivateKeyAlgorithm, #[serde(rename = "sa")] pub signature_algorithm: SignatureAlgorithm, #[serde(rename = "nb", with = "time::serde::timestamp")] @@ -44,8 +44,8 @@ pub struct SelfSignedCertificate { #[cfg(test)] mod tests { use crate::utils::{ - tests::MockSelfSignedCertificate, ExtendedKeyUsage, KeyAlgorithm, KeySize, KeyUsage, - SelfSignedCertificate, SignatureAlgorithm, Version, + tests::MockSelfSignedCertificate, ExtendedKeyUsage, KeyUsage, PrivateKeyAlgorithm, + PrivateKeySize, SelfSignedCertificate, SignatureAlgorithm, Version, }; use insta::assert_json_snapshot; use time::OffsetDateTime; @@ -60,7 +60,7 @@ mod tests { assert_json_snapshot!( MockSelfSignedCertificate::new( "test-2-name", - KeyAlgorithm::Ed25519, + PrivateKeyAlgorithm::Ed25519, SignatureAlgorithm::Ed25519, not_valid_before, not_valid_after, @@ -86,7 +86,7 @@ mod tests { "o": "CA Issuer, Inc", "ou": "CA Org Unit", "ka": { - "alg": "ed25519" + "keyType": "ed25519" }, "sa": "ed25519", "nb": 946720800, @@ -105,7 +105,7 @@ mod tests { assert_json_snapshot!( MockSelfSignedCertificate::new( "name", - KeyAlgorithm::Rsa { key_size: KeySize::Size1024 }, + PrivateKeyAlgorithm::Rsa { key_size: PrivateKeySize::Size1024 }, SignatureAlgorithm::Sha256, not_valid_before, not_valid_after, @@ -115,7 +115,7 @@ mod tests { { "n": "name", "ka": { - "alg": "rsa", + "keyType": "rsa", "keySize": "1024" }, "sa": "sha256", @@ -142,7 +142,7 @@ mod tests { r#" { "n": "name", - "ka": { "alg": "rsa", "keySize": "1024" }, + "ka": { "keyType": "rsa", "keySize": "1024" }, "sa": "sha256", "nb": 946720800, "na": 1262340000, @@ -153,8 +153,8 @@ mod tests { )?, MockSelfSignedCertificate::new( "name", - KeyAlgorithm::Rsa { - key_size: KeySize::Size1024 + PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024 }, SignatureAlgorithm::Sha256, not_valid_before, @@ -174,7 +174,7 @@ mod tests { "l": "San Francisco", "o": "CA Issuer, Inc", "ou": "CA Org Unit", - "ka": { "alg": "ed25519" }, + "ka": { "keyType": "ed25519" }, "sa": "ed25519", "nb": 946720800, "na": 1262340000, @@ -187,7 +187,7 @@ mod tests { )?, MockSelfSignedCertificate::new( "test-2-name", - KeyAlgorithm::Ed25519, + PrivateKeyAlgorithm::Ed25519, SignatureAlgorithm::Ed25519, not_valid_before, not_valid_after, diff --git a/src/utils/certificates/utils_certificates_action.rs b/src/utils/certificates/utils_certificates_action.rs index cdcd62d..8507c63 100644 --- a/src/utils/certificates/utils_certificates_action.rs +++ b/src/utils/certificates/utils_certificates_action.rs @@ -1,33 +1,15 @@ use crate::{ api::Api, + error::Error as SecutilsError, network::{DnsResolver, EmailTransport}, - users::{PublicUserDataNamespace, User}, + users::User, utils::{ - utils_action_validation::MAX_UTILS_ENTITY_NAME_LENGTH, CertificateFormat, ExtendedKeyUsage, - KeyAlgorithm, KeyUsage, SelfSignedCertificate, SignatureAlgorithm, + utils_action_validation::MAX_UTILS_ENTITY_NAME_LENGTH, ExportFormat, PrivateKeyAlgorithm, UtilsCertificatesActionResult, }, }; -use anyhow::{anyhow, Context}; -use openssl::{ - asn1::Asn1Time, - bn::{BigNum, MsbOption}, - dsa::Dsa, - ec::{EcGroup, EcKey}, - hash::MessageDigest, - nid::Nid, - pkcs12::Pkcs12, - pkey::{PKey, Private}, - rsa::Rsa, - symm::Cipher, - x509::{extension, X509NameBuilder, X509}, -}; +use anyhow::bail; use serde::Deserialize; -use std::{ - collections::BTreeMap, - io::{Cursor, Write}, -}; -use zip::{write::FileOptions, CompressionMethod, ZipWriter}; #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -36,29 +18,91 @@ pub enum UtilsCertificatesAction { #[serde(rename_all = "camelCase")] GenerateSelfSignedCertificate { template_name: String, - format: CertificateFormat, + format: ExportFormat, + passphrase: Option, + }, + GetPrivateKeys, + #[serde(rename_all = "camelCase")] + CreatePrivateKey { + key_name: String, + alg: PrivateKeyAlgorithm, + passphrase: Option, + }, + #[serde(rename_all = "camelCase")] + ChangePrivateKeyPassphrase { + key_name: String, passphrase: Option, + new_passphrase: Option, + }, + #[serde(rename_all = "camelCase")] + RemovePrivateKey { + key_name: String, + }, + #[serde(rename_all = "camelCase")] + ExportPrivateKey { + key_name: String, + format: ExportFormat, + passphrase: Option, + export_passphrase: Option, }, - GenerateRsaKeyPair, } impl UtilsCertificatesAction { /// Validates action parameters and throws if action parameters aren't valid. pub fn validate(&self) -> anyhow::Result<()> { + let assert_private_key_name = |name: &str| -> Result<(), SecutilsError> { + if name.is_empty() { + return Err(SecutilsError::client("Private key name cannot be empty.")); + } + + if name.len() > MAX_UTILS_ENTITY_NAME_LENGTH { + return Err(SecutilsError::client(format!( + "Private key name cannot be longer than {} characters.", + MAX_UTILS_ENTITY_NAME_LENGTH + ))); + } + + Ok(()) + }; + match self { UtilsCertificatesAction::GenerateSelfSignedCertificate { template_name, .. } => { if template_name.is_empty() { - anyhow::bail!("Template name cannot be empty"); + bail!(SecutilsError::client( + "Certificate template name cannot be empty." + )); } if template_name.len() > MAX_UTILS_ENTITY_NAME_LENGTH { - anyhow::bail!( - "Template name cannot be longer than {} characters", + bail!(SecutilsError::client(format!( + "Certificate template name cannot be longer than {} characters.", MAX_UTILS_ENTITY_NAME_LENGTH - ); + ))); + } + } + UtilsCertificatesAction::CreatePrivateKey { key_name: name, .. } => { + assert_private_key_name(name)?; + } + UtilsCertificatesAction::ChangePrivateKeyPassphrase { + key_name, + passphrase, + new_passphrase, + } => { + assert_private_key_name(key_name)?; + + if passphrase == new_passphrase { + bail!(SecutilsError::client(format!( + "New private key passphrase should be different from the current passphrase ({key_name})." + ))); } } - UtilsCertificatesAction::GenerateRsaKeyPair => {} + UtilsCertificatesAction::RemovePrivateKey { key_name } => { + assert_private_key_name(key_name)?; + } + UtilsCertificatesAction::ExportPrivateKey { key_name, .. } => { + assert_private_key_name(key_name)?; + } + UtilsCertificatesAction::GetPrivateKeys => {} } Ok(()) @@ -69,300 +113,83 @@ impl UtilsCertificatesAction { user: User, api: &Api, ) -> anyhow::Result { + let certificates = api.certificates(); match self { UtilsCertificatesAction::GenerateSelfSignedCertificate { template_name, format, passphrase, + } => Ok( + UtilsCertificatesActionResult::GenerateSelfSignedCertificate( + certificates + .generate_self_signed_certificate( + user.id, + &template_name, + format, + passphrase.as_deref(), + ) + .await?, + ), + ), + UtilsCertificatesAction::GetPrivateKeys => { + Ok(UtilsCertificatesActionResult::GetPrivateKeys( + certificates.get_private_keys(user.id).await?, + )) + } + UtilsCertificatesAction::CreatePrivateKey { + key_name, + alg, + passphrase, + } => Ok(UtilsCertificatesActionResult::CreatePrivateKey( + certificates + .create_private_key(user.id, &key_name, alg, passphrase.as_deref()) + .await?, + )), + UtilsCertificatesAction::ChangePrivateKeyPassphrase { + key_name, + passphrase, + new_passphrase, } => { - let certificate_template = api - .users() - .get_data::>( + certificates + .change_private_key_passphrase( user.id, - PublicUserDataNamespace::SelfSignedCertificates, + &key_name, + passphrase.as_deref(), + new_passphrase.as_deref(), ) - .await? - .and_then(|mut map| map.value.remove(&template_name)) - .ok_or_else(|| { - anyhow!( - "Cannot find self-signed certificate with name: {}", - template_name - ) - })?; - - let key = generate_key(certificate_template.key_algorithm)?; - let certificate = match format { - CertificateFormat::Pem => { - convert_to_pem_archive(certificate_template, key, passphrase)? - } - CertificateFormat::Pkcs8 => convert_to_pkcs8(key, passphrase)?, - CertificateFormat::Pkcs12 => { - convert_to_pkcs12(certificate_template, key, passphrase)? - } - }; - - log::info!("Serialized certificate ({} bytes).", certificate.len()); - - Ok( - UtilsCertificatesActionResult::GenerateSelfSignedCertificate { - format, - certificate, - }, - ) + .await?; + Ok(UtilsCertificatesActionResult::ChangePrivateKeyPassphrase) } - UtilsCertificatesAction::GenerateRsaKeyPair => { - let rsa = Rsa::generate(2048)?; - let public_pem = rsa.public_key_to_pem()?; - - Ok(UtilsCertificatesActionResult::GenerateRsaKeyPair( - public_pem, - )) + UtilsCertificatesAction::ExportPrivateKey { + key_name, + passphrase, + export_passphrase, + format, + } => Ok(UtilsCertificatesActionResult::ExportPrivateKey( + certificates + .export_private_key( + user.id, + &key_name, + format, + passphrase.as_deref(), + export_passphrase.as_deref(), + ) + .await?, + )), + UtilsCertificatesAction::RemovePrivateKey { key_name } => { + certificates.remove_private_key(user.id, &key_name).await?; + Ok(UtilsCertificatesActionResult::RemovePrivateKey) } } } } -fn set_name_attribute( - x509_name: &mut X509NameBuilder, - attribute_key: &str, - attribute_value: &Option, -) -> anyhow::Result<()> { - if attribute_key.is_empty() { - return Ok(()); - } - - if let Some(attribute_value) = attribute_value { - if !attribute_value.is_empty() { - x509_name.append_entry_by_text(attribute_key, attribute_value)?; - } - } - - Ok(()) -} - -fn message_digest( - pk_alg: KeyAlgorithm, - sig_alg: SignatureAlgorithm, -) -> anyhow::Result { - match (pk_alg, sig_alg) { - (KeyAlgorithm::Rsa { .. }, SignatureAlgorithm::Md5) => Ok(MessageDigest::md5()), - ( - KeyAlgorithm::Rsa { .. } | KeyAlgorithm::Dsa { .. } | KeyAlgorithm::Ecdsa { .. }, - SignatureAlgorithm::Sha1, - ) => Ok(MessageDigest::sha1()), - ( - KeyAlgorithm::Rsa { .. } | KeyAlgorithm::Dsa { .. } | KeyAlgorithm::Ecdsa { .. }, - SignatureAlgorithm::Sha256, - ) => Ok(MessageDigest::sha256()), - (KeyAlgorithm::Rsa { .. } | KeyAlgorithm::Ecdsa { .. }, SignatureAlgorithm::Sha384) => { - Ok(MessageDigest::sha384()) - } - (KeyAlgorithm::Rsa { .. } | KeyAlgorithm::Ecdsa { .. }, SignatureAlgorithm::Sha512) => { - Ok(MessageDigest::sha512()) - } - (KeyAlgorithm::Ed25519, SignatureAlgorithm::Ed25519) => Ok(MessageDigest::null()), - _ => Err(anyhow!( - "Public key ({:?}) and signature ({:?}) algorithms are not compatible", - pk_alg, - sig_alg - )), - } -} - -fn convert_to_pem_archive( - certificate_template: SelfSignedCertificate, - key_pair: PKey, - passphrase: Option, -) -> anyhow::Result> { - let certificate = generate_x509_certificate(&certificate_template, &key_pair)?; - - // 64kb should be more than enough for the certificate + private key. - let mut zip_buffer = [0; 65536]; - let size = { - let mut zip = ZipWriter::new(Cursor::new(&mut zip_buffer[..])); - - let options = FileOptions::default().compression_method(CompressionMethod::Deflated); - zip.start_file("certificate.crt", options)?; - zip.write_all(&certificate.to_pem()?)?; - - zip.start_file("private_key.key", options)?; - zip.write_all(&match passphrase { - None => key_pair.private_key_to_pem_pkcs8()?, - Some(passphrase) => key_pair.private_key_to_pem_pkcs8_passphrase( - Cipher::aes_128_cbc(), - passphrase.as_bytes(), - )?, - })?; - - zip.finish()?.position() as usize - }; - - Ok(zip_buffer[..size].to_vec()) -} - -fn convert_to_pkcs8( - key_pair: PKey, - passphrase: Option, -) -> anyhow::Result> { - let pkcs8 = if let Some(passphrase) = passphrase { - // AEAD ciphers not supported in this command. - key_pair.private_key_to_pkcs8_passphrase(Cipher::aes_128_cbc(), passphrase.as_bytes()) - } else { - key_pair.private_key_to_pkcs8() - }; - - pkcs8.with_context(|| "Cannot convert private key to PKCS8.") -} - -fn convert_to_pkcs12( - certificate_template: SelfSignedCertificate, - key_pair: PKey, - passphrase: Option, -) -> anyhow::Result> { - let certificate = generate_x509_certificate(&certificate_template, &key_pair)?; - - let mut pkcs12_builder = Pkcs12::builder(); - let pkcs12 = pkcs12_builder - .name(&certificate_template.name) - .pkey(&key_pair) - .cert(&certificate) - .build2(&passphrase.unwrap_or_default()) - .with_context(|| "Cannot build PKCS12 certificate bundle.")?; - - pkcs12 - .to_der() - .with_context(|| "Cannot convert PKCS12 certificate bundle to DER.") -} - -fn generate_key(public_key_algorithm: KeyAlgorithm) -> anyhow::Result> { - let private_key = match public_key_algorithm { - KeyAlgorithm::Rsa { key_size } => { - let rsa = Rsa::generate(key_size as u32)?; - PKey::from_rsa(rsa)? - } - KeyAlgorithm::Dsa { key_size } => { - let dsa = Dsa::generate(key_size as u32)?; - PKey::from_dsa(dsa)? - } - KeyAlgorithm::Ecdsa { curve } => { - let ec_group = EcGroup::from_curve_name(Nid::from_raw(curve as i32))?; - PKey::from_ec_key(EcKey::generate(&ec_group)?)? - } - KeyAlgorithm::Ed25519 => PKey::generate_ed25519()?, - }; - - Ok(private_key) -} - -fn generate_x509_certificate( - certificate_template: &SelfSignedCertificate, - key: &PKey, -) -> anyhow::Result { - let mut x509_name = X509NameBuilder::new()?; - set_name_attribute(&mut x509_name, "CN", &certificate_template.common_name)?; - set_name_attribute(&mut x509_name, "C", &certificate_template.country)?; - set_name_attribute( - &mut x509_name, - "ST", - &certificate_template.state_or_province, - )?; - set_name_attribute(&mut x509_name, "L", &certificate_template.locality)?; - set_name_attribute(&mut x509_name, "O", &certificate_template.organization)?; - set_name_attribute( - &mut x509_name, - "OU", - &certificate_template.organizational_unit, - )?; - let x509_name = x509_name.build(); - - let mut x509 = X509::builder()?; - x509.set_subject_name(&x509_name)?; - x509.set_issuer_name(&x509_name)?; - x509.set_version(certificate_template.version.value())?; - - let mut basic_constraint = extension::BasicConstraints::new(); - if certificate_template.is_ca { - basic_constraint.ca(); - } - x509.append_extension(basic_constraint.critical().build()?)?; - - let serial_number = { - let mut serial = BigNum::new()?; - serial.rand(159, MsbOption::MAYBE_ZERO, false)?; - serial.to_asn1_integer()? - }; - x509.set_serial_number(&serial_number)?; - - x509.set_pubkey(key)?; - let not_before = Asn1Time::from_unix(certificate_template.not_valid_before.unix_timestamp())?; - x509.set_not_before(¬_before)?; - let not_after = Asn1Time::from_unix(certificate_template.not_valid_after.unix_timestamp())?; - x509.set_not_after(¬_after)?; - - if let Some(ref key_usage) = certificate_template.key_usage { - let mut key_usage_ext = extension::KeyUsage::new(); - - for key_usage in key_usage { - match key_usage { - KeyUsage::DigitalSignature => key_usage_ext.digital_signature(), - KeyUsage::NonRepudiation => key_usage_ext.non_repudiation(), - KeyUsage::KeyEncipherment => key_usage_ext.key_encipherment(), - KeyUsage::DataEncipherment => key_usage_ext.data_encipherment(), - KeyUsage::KeyAgreement => key_usage_ext.key_agreement(), - KeyUsage::KeyCertificateSigning => key_usage_ext.key_cert_sign(), - KeyUsage::CrlSigning => key_usage_ext.crl_sign(), - KeyUsage::EncipherOnly => key_usage_ext.encipher_only(), - KeyUsage::DecipherOnly => key_usage_ext.decipher_only(), - }; - } - - x509.append_extension(key_usage_ext.critical().build()?)?; - } - - if let Some(ref key_usage) = certificate_template.extended_key_usage { - let mut key_usage_ext = extension::ExtendedKeyUsage::new(); - - for key_usage in key_usage { - match key_usage { - ExtendedKeyUsage::TlsWebServerAuthentication => key_usage_ext.server_auth(), - ExtendedKeyUsage::TlsWebClientAuthentication => key_usage_ext.client_auth(), - ExtendedKeyUsage::CodeSigning => key_usage_ext.code_signing(), - ExtendedKeyUsage::EmailProtection => key_usage_ext.email_protection(), - ExtendedKeyUsage::TimeStamping => key_usage_ext.time_stamping(), - }; - } - - x509.append_extension(key_usage_ext.critical().build()?)?; - } - - let subject_key_identifier = - extension::SubjectKeyIdentifier::new().build(&x509.x509v3_context(None, None))?; - x509.append_extension(subject_key_identifier)?; - - x509.sign( - key, - message_digest( - certificate_template.key_algorithm, - certificate_template.signature_algorithm, - )?, - )?; - - Ok(x509.build()) -} - #[cfg(test)] mod tests { use crate::utils::{ - certificates::utils_certificates_action::{ - generate_key, generate_x509_certificate, message_digest, - }, - tests::MockSelfSignedCertificate, - CertificateFormat, EllipticCurve, KeyAlgorithm, KeySize, SignatureAlgorithm, - UtilsCertificatesAction, Version, + ExportFormat, PrivateKeyAlgorithm, PrivateKeySize, UtilsCertificatesAction, }; use insta::assert_debug_snapshot; - use openssl::hash::MessageDigest; - use time::OffsetDateTime; #[test] fn deserialization() -> anyhow::Result<()> { @@ -377,7 +204,7 @@ mod tests { )?, UtilsCertificatesAction::GenerateSelfSignedCertificate { template_name: "template".to_string(), - format: CertificateFormat::Pem, + format: ExportFormat::Pem, passphrase: None, } ); @@ -392,19 +219,136 @@ mod tests { )?, UtilsCertificatesAction::GenerateSelfSignedCertificate { template_name: "template".to_string(), - format: CertificateFormat::Pkcs12, + format: ExportFormat::Pkcs12, passphrase: Some("phrase".to_string()), } ); + assert_eq!( serde_json::from_str::( r#" { - "type": "generateRsaKeyPair" + "type": "getPrivateKeys" } "# )?, - UtilsCertificatesAction::GenerateRsaKeyPair + UtilsCertificatesAction::GetPrivateKeys + ); + + assert_eq!( + serde_json::from_str::( + r#" +{ + "type": "createPrivateKey", + "value": { "keyName": "pk", "alg": {"keyType": "rsa", "keySize": "1024"}, "passphrase": "phrase" } +} + "# + )?, + UtilsCertificatesAction::CreatePrivateKey { + key_name: "pk".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024 + }, + passphrase: Some("phrase".to_string()), + } + ); + + assert_eq!( + serde_json::from_str::( + r#" +{ + "type": "createPrivateKey", + "value": { "keyName": "pk", "alg": {"keyType": "rsa", "keySize": "1024"} } +} + "# + )?, + UtilsCertificatesAction::CreatePrivateKey { + key_name: "pk".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024 + }, + passphrase: None, + } + ); + + assert_eq!( + serde_json::from_str::( + r#" +{ + "type": "changePrivateKeyPassphrase", + "value": { "keyName": "pk", "passphrase": "phrase", "newPassphrase": "phrase_new" } +} + "# + )?, + UtilsCertificatesAction::ChangePrivateKeyPassphrase { + key_name: "pk".to_string(), + passphrase: Some("phrase".to_string()), + new_passphrase: Some("phrase_new".to_string()), + } + ); + + assert_eq!( + serde_json::from_str::( + r#" +{ + "type": "changePrivateKeyPassphrase", + "value": { "keyName": "pk" } +} + "# + )?, + UtilsCertificatesAction::ChangePrivateKeyPassphrase { + key_name: "pk".to_string(), + passphrase: None, + new_passphrase: None, + } + ); + + assert_eq!( + serde_json::from_str::( + r#" +{ + "type": "removePrivateKey", + "value": { "keyName": "pk" } +} + "# + )?, + UtilsCertificatesAction::RemovePrivateKey { + key_name: "pk".to_string(), + } + ); + + assert_eq!( + serde_json::from_str::( + r#" +{ + "type": "exportPrivateKey", + "value": { "keyName": "pk", "format": "pem", "passphrase": "phrase", "exportPassphrase": "phrase_new" } +} + "# + )?, + UtilsCertificatesAction::ExportPrivateKey { + key_name: "pk".to_string(), + format: ExportFormat::Pem, + passphrase: Some("phrase".to_string()), + export_passphrase: Some("phrase_new".to_string()), + } + ); + + assert_eq!( + serde_json::from_str::( + r#" +{ + "type": "exportPrivateKey", + "value": { "keyName": "pk", "format": "pem" } +} + "# + )?, + UtilsCertificatesAction::ExportPrivateKey { + key_name: "pk".to_string(), + format: ExportFormat::Pem, + passphrase: None, + export_passphrase: None, + } ); Ok(()) @@ -412,13 +356,9 @@ mod tests { #[test] fn validation() -> anyhow::Result<()> { - assert!(UtilsCertificatesAction::GenerateRsaKeyPair - .validate() - .is_ok()); - assert!(UtilsCertificatesAction::GenerateSelfSignedCertificate { template_name: "a".repeat(100), - format: CertificateFormat::Pem, + format: ExportFormat::Pem, passphrase: None, } .validate() @@ -426,144 +366,95 @@ mod tests { assert_debug_snapshot!(UtilsCertificatesAction::GenerateSelfSignedCertificate { template_name: "".to_string(), - format: CertificateFormat::Pem, + format: ExportFormat::Pem, passphrase: None, }.validate(), @r###" Err( - "Template name cannot be empty", + "Certificate template name cannot be empty.", ) "###); assert_debug_snapshot!(UtilsCertificatesAction::GenerateSelfSignedCertificate { template_name: "a".repeat(101), - format: CertificateFormat::Pem, + format: ExportFormat::Pem, passphrase: None, }.validate(), @r###" Err( - "Template name cannot be longer than 100 characters", + "Certificate template name cannot be longer than 100 characters.", ) "###); - Ok(()) - } - - #[test] - fn correctly_generate_keys() -> anyhow::Result<()> { - let rsa_key = generate_key(KeyAlgorithm::Rsa { - key_size: KeySize::Size1024, - })?; - let rsa_key = rsa_key.rsa()?; - - assert!(rsa_key.check_key()?); - assert_eq!(rsa_key.size(), 128); - - let dsa_key = generate_key(KeyAlgorithm::Dsa { - key_size: KeySize::Size2048, - })?; - let dsa_key = dsa_key.dsa()?; - - assert_eq!(dsa_key.size(), 72); - - let ecdsa_key = generate_key(KeyAlgorithm::Ecdsa { - curve: EllipticCurve::SECP256R1, - })?; - let ecdsa_key = ecdsa_key.ec_key()?; - - ecdsa_key.check_key()?; - - let ed25519_key = generate_key(KeyAlgorithm::Ed25519)?; - assert_eq!(ed25519_key.bits(), 256); - - Ok(()) - } - - #[test] - fn picks_correct_message_digest() -> anyhow::Result<()> { - assert!( - message_digest( - KeyAlgorithm::Rsa { - key_size: KeySize::Size1024, + let get_actions_with_name = |key_name: String| { + vec![ + UtilsCertificatesAction::CreatePrivateKey { + key_name: key_name.clone(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size1024, + }, + passphrase: Some("phrase".to_string()), }, - SignatureAlgorithm::Md5 - )? == MessageDigest::md5() - ); + UtilsCertificatesAction::ChangePrivateKeyPassphrase { + key_name: key_name.clone(), + passphrase: Some("pass".to_string()), + new_passphrase: Some("pass_new".to_string()), + }, + UtilsCertificatesAction::ExportPrivateKey { + key_name: key_name.clone(), + format: ExportFormat::Pem, + passphrase: None, + export_passphrase: None, + }, + UtilsCertificatesAction::RemovePrivateKey { + key_name: key_name.clone(), + }, + ] + }; - for pk_algorithm in [ - KeyAlgorithm::Rsa { - key_size: KeySize::Size1024, - }, - KeyAlgorithm::Dsa { - key_size: KeySize::Size2048, - }, - KeyAlgorithm::Ecdsa { - curve: EllipticCurve::SECP256R1, - }, - ] { - assert!( - message_digest(pk_algorithm, SignatureAlgorithm::Sha1)? == MessageDigest::sha1() + for action in get_actions_with_name("a".repeat(100)) { + assert!(action.validate().is_ok()); + } + + for action in get_actions_with_name("".to_string()) { + assert_eq!( + action.validate().map_err(|err| err.to_string()), + Err("Private key name cannot be empty.".to_string()) ); - assert!( - message_digest(pk_algorithm, SignatureAlgorithm::Sha256)? - == MessageDigest::sha256() + } + + for action in get_actions_with_name("a".repeat(101)) { + assert_eq!( + action.validate().map_err(|err| err.to_string()), + Err("Private key name cannot be longer than 100 characters.".to_string()) ); } - for pk_algorithm in [ - KeyAlgorithm::Rsa { - key_size: KeySize::Size1024, - }, - KeyAlgorithm::Ecdsa { - curve: EllipticCurve::SECP256R1, - }, + for (passphrase, new_passphrase) in [ + (None, None), + (Some("pass".to_string()), Some("pass".to_string())), ] { - assert!( - message_digest(pk_algorithm, SignatureAlgorithm::Sha384)? - == MessageDigest::sha384() - ); - assert!( - message_digest(pk_algorithm, SignatureAlgorithm::Sha512)? - == MessageDigest::sha512() + let change_password_action = UtilsCertificatesAction::ChangePrivateKeyPassphrase { + key_name: "pk".to_string(), + passphrase, + new_passphrase, + }; + assert_eq!( + change_password_action.validate().map_err(|err| err.to_string()), + Err("New private key passphrase should be different from the current passphrase (pk).".to_string()) ); } - assert!( - message_digest(KeyAlgorithm::Ed25519, SignatureAlgorithm::Ed25519)? - == MessageDigest::null() - ); - - Ok(()) - } - - #[test] - fn correctly_generates_x509_certificate() -> anyhow::Result<()> { - // January 1, 2000 11:00:00 - let not_valid_before = OffsetDateTime::from_unix_timestamp(946720800)?; - // January 1, 2010 11:00:00 - let not_valid_after = OffsetDateTime::from_unix_timestamp(1262340000)?; - - let certificate_template = MockSelfSignedCertificate::new( - "test-1-name", - KeyAlgorithm::Rsa { - key_size: KeySize::Size1024, - }, - SignatureAlgorithm::Sha256, - not_valid_before, - not_valid_after, - Version::One, - ) - .build(); - let key = generate_key(KeyAlgorithm::Rsa { - key_size: KeySize::Size1024, - })?; - - let x509_certificate = generate_x509_certificate(&certificate_template, &key)?; - - assert_debug_snapshot!(x509_certificate.not_before(), @"Jan 1 10:00:00 2000 GMT"); - assert_debug_snapshot!(x509_certificate.not_after(), @"Jan 1 10:00:00 2010 GMT"); - assert_eq!( - x509_certificate.public_key()?.public_key_to_der()?, - key.public_key_to_der()? - ); + for (passphrase, new_passphrase) in [ + (None, Some("pass".to_string())), + (Some("pass".to_string()), Some("pass_new".to_string())), + (Some("pass".to_string()), None), + ] { + let change_password_action = UtilsCertificatesAction::ChangePrivateKeyPassphrase { + key_name: "pk".to_string(), + passphrase, + new_passphrase, + }; + assert!(change_password_action.validate().is_ok()); + } Ok(()) } diff --git a/src/utils/certificates/utils_certificates_action_result.rs b/src/utils/certificates/utils_certificates_action_result.rs index 85c3857..03dd475 100644 --- a/src/utils/certificates/utils_certificates_action_result.rs +++ b/src/utils/certificates/utils_certificates_action_result.rs @@ -1,4 +1,4 @@ -use crate::utils::CertificateFormat; +use crate::utils::PrivateKey; use serde::Serialize; #[derive(Serialize)] @@ -6,39 +6,100 @@ use serde::Serialize; #[serde(tag = "type", content = "value")] pub enum UtilsCertificatesActionResult { #[serde(rename_all = "camelCase")] - GenerateSelfSignedCertificate { - certificate: Vec, - format: CertificateFormat, - }, - GenerateRsaKeyPair(Vec), + GenerateSelfSignedCertificate(Vec), + GetPrivateKeys(Vec), + CreatePrivateKey(PrivateKey), + ChangePrivateKeyPassphrase, + RemovePrivateKey, + ExportPrivateKey(Vec), } #[cfg(test)] mod tests { - use crate::utils::{CertificateFormat, UtilsCertificatesActionResult}; + use crate::utils::{ + PrivateKey, PrivateKeyAlgorithm, PrivateKeySize, UtilsCertificatesActionResult, + }; use insta::assert_json_snapshot; + use time::OffsetDateTime; #[test] fn serialization() -> anyhow::Result<()> { - assert_json_snapshot!(UtilsCertificatesActionResult::GenerateSelfSignedCertificate { - certificate: vec![1,2,3], - format: CertificateFormat::Pem - }, @r###" + assert_json_snapshot!(UtilsCertificatesActionResult::GenerateSelfSignedCertificate (vec![1,2,3]), @r###" { "type": "generateSelfSignedCertificate", + "value": [ + 1, + 2, + 3 + ] + } + "###); + + assert_json_snapshot!(UtilsCertificatesActionResult::GetPrivateKeys(vec![PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048 + }, + pkcs8: vec![], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }]), @r###" + { + "type": "getPrivateKeys", + "value": [ + { + "name": "pk-name", + "alg": { + "keyType": "rsa", + "keySize": "2048" + }, + "pkcs8": [], + "createdAt": 946720800 + } + ] + } + "###); + + assert_json_snapshot!(UtilsCertificatesActionResult::CreatePrivateKey(PrivateKey { + name: "pk-name".to_string(), + alg: PrivateKeyAlgorithm::Rsa { + key_size: PrivateKeySize::Size2048 + }, + pkcs8: vec![1, 2, 3], + created_at: OffsetDateTime::from_unix_timestamp(946720800)?, + }), @r###" + { + "type": "createPrivateKey", "value": { - "certificate": [ + "name": "pk-name", + "alg": { + "keyType": "rsa", + "keySize": "2048" + }, + "pkcs8": [ 1, 2, 3 ], - "format": "pem" + "createdAt": 946720800 } } "###); - assert_json_snapshot!(UtilsCertificatesActionResult::GenerateRsaKeyPair(vec![1,2,3]), @r###" + + assert_json_snapshot!(UtilsCertificatesActionResult::ChangePrivateKeyPassphrase, @r###" + { + "type": "changePrivateKeyPassphrase" + } + "###); + + assert_json_snapshot!(UtilsCertificatesActionResult::RemovePrivateKey, @r###" + { + "type": "removePrivateKey" + } + "###); + + assert_json_snapshot!(UtilsCertificatesActionResult::ExportPrivateKey(vec![1, 2, 3]), @r###" { - "type": "generateRsaKeyPair", + "type": "exportPrivateKey", "value": [ 1, 2, diff --git a/src/utils/certificates/x509.rs b/src/utils/certificates/x509.rs index 8a58cde..8d29eee 100644 --- a/src/utils/certificates/x509.rs +++ b/src/utils/certificates/x509.rs @@ -1,13 +1,9 @@ -mod elliptic_curve; mod extended_key_usage; -mod key_algorithm; -mod key_size; mod key_usage; mod signature_algorithm; mod version; pub use self::{ - elliptic_curve::EllipticCurve, extended_key_usage::ExtendedKeyUsage, - key_algorithm::KeyAlgorithm, key_size::KeySize, key_usage::KeyUsage, + extended_key_usage::ExtendedKeyUsage, key_usage::KeyUsage, signature_algorithm::SignatureAlgorithm, version::Version, }; diff --git a/src/utils/certificates/x509/elliptic_curve.rs b/src/utils/certificates/x509/elliptic_curve.rs deleted file mode 100644 index 9593571..0000000 --- a/src/utils/certificates/x509/elliptic_curve.rs +++ /dev/null @@ -1,54 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Defines named elliptic curves used with Elliptic Curve Digital Signature Algorithm (ECDSA). -/// See https://www.rfc-editor.org/rfc/rfc8422.html#appendix-A. -#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)] -#[serde(rename_all = "lowercase")] -pub enum EllipticCurve { - /// Elliptic curve prime256v1 (ANSI X9.62) / secp256r1 (SECG) / NIST P-256 (NIST). - SECP256R1 = 415, - /// Elliptic curve secp384r1 (SECG) / NIST P-384 (NIST). - SECP384R1 = 715, - /// Elliptic curve secp521r1 (SECG) / NIST P-521 (NIST). - SECP521R1 = 716, -} - -#[cfg(test)] -mod tests { - use crate::utils::EllipticCurve; - use insta::assert_json_snapshot; - - #[test] - fn serialization() { - assert_json_snapshot!(EllipticCurve::SECP256R1, @r###""secp256r1""###); - assert_json_snapshot!(EllipticCurve::SECP384R1, @r###""secp384r1""###); - assert_json_snapshot!(EllipticCurve::SECP521R1, @r###""secp521r1""###); - } - - #[test] - fn deserialization() -> anyhow::Result<()> { - assert_eq!( - serde_json::from_str::(r#""secp256r1""#)?, - EllipticCurve::SECP256R1 - ); - - assert_eq!( - serde_json::from_str::(r#""secp384r1""#)?, - EllipticCurve::SECP384R1 - ); - - assert_eq!( - serde_json::from_str::(r#""secp521r1""#)?, - EllipticCurve::SECP521R1 - ); - - Ok(()) - } - - #[test] - fn as_number() { - assert_eq!(EllipticCurve::SECP256R1 as u32, 415); - assert_eq!(EllipticCurve::SECP384R1 as u32, 715); - assert_eq!(EllipticCurve::SECP521R1 as u32, 716); - } -} diff --git a/src/utils/certificates/x509/key_algorithm.rs b/src/utils/certificates/x509/key_algorithm.rs deleted file mode 100644 index 3a598e4..0000000 --- a/src/utils/certificates/x509/key_algorithm.rs +++ /dev/null @@ -1,83 +0,0 @@ -use crate::utils::{EllipticCurve, KeySize}; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -#[serde(tag = "alg")] -pub enum KeyAlgorithm { - #[serde(rename_all = "camelCase")] - Rsa { - key_size: KeySize, - }, - #[serde(rename_all = "camelCase")] - Dsa { - key_size: KeySize, - }, - Ecdsa { - curve: EllipticCurve, - }, - Ed25519, -} - -#[cfg(test)] -mod tests { - use crate::utils::{EllipticCurve, KeyAlgorithm, KeySize}; - use insta::assert_json_snapshot; - - #[test] - fn serialization() -> anyhow::Result<()> { - assert_json_snapshot!(KeyAlgorithm::Rsa { key_size: KeySize::Size1024 }, @r###" - { - "alg": "rsa", - "keySize": "1024" - } - "###); - assert_json_snapshot!(KeyAlgorithm::Dsa { key_size: KeySize::Size2048 }, @r###" - { - "alg": "dsa", - "keySize": "2048" - } - "###); - assert_json_snapshot!(KeyAlgorithm::Ecdsa { curve: EllipticCurve::SECP256R1 }, @r###" - { - "alg": "ecdsa", - "curve": "secp256r1" - } - "###); - assert_json_snapshot!(KeyAlgorithm::Ed25519, @r###" - { - "alg": "ed25519" - } - "###); - - Ok(()) - } - - #[test] - fn deserialization() -> anyhow::Result<()> { - assert_eq!( - serde_json::from_str::(r#"{ "alg": "rsa", "keySize": "1024" }"#)?, - KeyAlgorithm::Rsa { - key_size: KeySize::Size1024 - } - ); - assert_eq!( - serde_json::from_str::(r#"{ "alg": "dsa", "keySize": "2048" }"#)?, - KeyAlgorithm::Dsa { - key_size: KeySize::Size2048 - } - ); - assert_eq!( - serde_json::from_str::(r#"{ "alg": "ecdsa", "curve": "secp256r1" }"#)?, - KeyAlgorithm::Ecdsa { - curve: EllipticCurve::SECP256R1 - } - ); - assert_eq!( - serde_json::from_str::(r#"{ "alg": "ed25519" }"#)?, - KeyAlgorithm::Ed25519 - ); - - Ok(()) - } -} diff --git a/src/utils/certificates/x509/key_size.rs b/src/utils/certificates/x509/key_size.rs deleted file mode 100644 index 3ac50d9..0000000 --- a/src/utils/certificates/x509/key_size.rs +++ /dev/null @@ -1,62 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// The key size defines a number of bits in a key used by a cryptographic algorithm. -#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)] -#[serde(rename_all = "camelCase")] -pub enum KeySize { - #[serde(rename = "1024")] - Size1024 = 1024, - #[serde(rename = "2048")] - Size2048 = 2048, - #[serde(rename = "4096")] - Size4096 = 4096, - #[serde(rename = "8192")] - Size8192 = 8192, -} - -#[cfg(test)] -mod tests { - use crate::utils::KeySize; - use insta::assert_json_snapshot; - - #[test] - fn serialization() { - assert_json_snapshot!(KeySize::Size1024, @r###""1024""###); - assert_json_snapshot!(KeySize::Size2048, @r###""2048""###); - assert_json_snapshot!(KeySize::Size4096, @r###""4096""###); - assert_json_snapshot!(KeySize::Size8192, @r###""8192""###); - } - - #[test] - fn deserialization() -> anyhow::Result<()> { - assert_eq!( - serde_json::from_str::(r#""1024""#)?, - KeySize::Size1024 - ); - - assert_eq!( - serde_json::from_str::(r#""2048""#)?, - KeySize::Size2048 - ); - - assert_eq!( - serde_json::from_str::(r#""4096""#)?, - KeySize::Size4096 - ); - - assert_eq!( - serde_json::from_str::(r#""8192""#)?, - KeySize::Size8192 - ); - - Ok(()) - } - - #[test] - fn as_number() { - assert_eq!(KeySize::Size1024 as u32, 1024); - assert_eq!(KeySize::Size2048 as u32, 2048); - assert_eq!(KeySize::Size4096 as u32, 4096); - assert_eq!(KeySize::Size8192 as u32, 8192); - } -} diff --git a/src/utils/utils_action.rs b/src/utils/utils_action.rs index df0d155..1587d5f 100644 --- a/src/utils/utils_action.rs +++ b/src/utils/utils_action.rs @@ -68,7 +68,7 @@ mod tests { mock_api, mock_api_with_network, MockResolver, MockWebPageResourcesTrackerBuilder, }, utils::{ - AutoResponder, AutoResponderMethod, CertificateFormat, ContentSecurityPolicySource, + AutoResponder, AutoResponderMethod, ContentSecurityPolicySource, ExportFormat, UtilsAction, UtilsCertificatesAction, UtilsWebScrapingAction, UtilsWebSecurityAction, UtilsWebhooksAction, }, @@ -95,7 +95,7 @@ mod tests { assert!(UtilsAction::Certificates( UtilsCertificatesAction::GenerateSelfSignedCertificate { template_name: "a".repeat(100), - format: CertificateFormat::Pem, + format: ExportFormat::Pem, passphrase: None, } ) @@ -105,11 +105,11 @@ mod tests { assert_debug_snapshot!(UtilsAction::Certificates(UtilsCertificatesAction::GenerateSelfSignedCertificate { template_name: "".to_string(), - format: CertificateFormat::Pem, + format: ExportFormat::Pem, passphrase: None, }).validate(&mock_api().await?).await, @r###" Err( - "Template name cannot be empty", + "Certificate template name cannot be empty.", ) "###); @@ -148,7 +148,7 @@ mod tests { }) .validate(&mock_api().await?).await, @r###" Err( - "Auto responder is not valid", + "Auto responder is not valid.", ) "###); @@ -166,7 +166,7 @@ mod tests { }) .validate(&mock_api().await?).await, @r###" Err( - "Auto responder path is not valid", + "Auto responder path is not valid.", ) "###); @@ -184,7 +184,7 @@ mod tests { }) .validate(&mock_api().await?).await, @r###" Err( - "Auto responder path is not valid", + "Auto responder path is not valid.", ) "###); @@ -224,7 +224,7 @@ mod tests { }) .validate(&mock_api().await?).await, @r###" Err( - "Tracker name cannot be empty", + "Tracker name cannot be empty.", ) "###); @@ -249,7 +249,7 @@ mod tests { }) .validate(&mock_api().await?).await, @r###" Err( - "Policy name cannot be empty", + "Policy name cannot be empty.", ) "###); diff --git a/src/utils/web_scraping/utils_web_scraping_action.rs b/src/utils/web_scraping/utils_web_scraping_action.rs index 7b7792d..873a0d4 100644 --- a/src/utils/web_scraping/utils_web_scraping_action.rs +++ b/src/utils/web_scraping/utils_web_scraping_action.rs @@ -1,5 +1,6 @@ use crate::{ api::Api, + error::Error as SecutilsError, network::{DnsResolver, EmailTransport}, users::User, utils::{ @@ -11,7 +12,7 @@ use crate::{ UtilsWebScrapingActionResult, WebPageResourcesTracker, }, }; -use anyhow::anyhow; +use anyhow::{anyhow, bail}; use cron::Schedule; use humantime::format_duration; use serde::Deserialize; @@ -43,61 +44,56 @@ impl UtilsWebScrapingAction { &self, api: &Api, ) -> anyhow::Result<()> { + let assert_tracker_name = |name: &str| -> Result<(), SecutilsError> { + if name.is_empty() { + return Err(SecutilsError::client("Tracker name cannot be empty.")); + } + + if name.len() > MAX_UTILS_ENTITY_NAME_LENGTH { + return Err(SecutilsError::client(format!( + "Tracker name cannot be longer than {} characters.", + MAX_UTILS_ENTITY_NAME_LENGTH + ))); + } + + Ok(()) + }; + match self { UtilsWebScrapingAction::FetchWebPageResources { tracker_name, .. } | UtilsWebScrapingAction::RemoveWebPageResources { tracker_name, .. } | UtilsWebScrapingAction::RemoveWebPageResourcesTracker { tracker_name } => { - if tracker_name.is_empty() { - anyhow::bail!("Tracker name cannot be empty"); - } - - if tracker_name.len() > MAX_UTILS_ENTITY_NAME_LENGTH { - anyhow::bail!( - "Tracker name cannot be longer than {} characters", - MAX_UTILS_ENTITY_NAME_LENGTH - ); - } + assert_tracker_name(tracker_name)?; } UtilsWebScrapingAction::SaveWebPageResourcesTracker { tracker } => { - if tracker.name.is_empty() { - anyhow::bail!("Tracker name cannot be empty"); - } - - if tracker.name.len() > MAX_UTILS_ENTITY_NAME_LENGTH { - anyhow::bail!( - "Tracker name cannot be longer than {} characters", - MAX_UTILS_ENTITY_NAME_LENGTH - ); - } + assert_tracker_name(&tracker.name)?; if tracker.revisions > MAX_WEB_PAGE_RESOURCES_TRACKER_REVISIONS { - anyhow::bail!( - "Tracker revisions count cannot be greater than {}", + bail!(SecutilsError::client(format!( + "Tracker revisions count cannot be greater than {}.", MAX_WEB_PAGE_RESOURCES_TRACKER_REVISIONS - ); + ))); } if tracker.delay > MAX_WEB_PAGE_RESOURCES_TRACKER_DELAY { - anyhow::bail!( - "Tracker delay cannot be greater than {}ms", + bail!(SecutilsError::client(format!( + "Tracker delay cannot be greater than {}ms.", MAX_WEB_PAGE_RESOURCES_TRACKER_DELAY.as_millis() - ); + ))); } if let Some(ref resource_filter) = tracker.scripts.resource_filter_map { if resource_filter.is_empty() { - anyhow::bail!("Tracker resource filter script cannot be empty"); + bail!(SecutilsError::client( + "Tracker resource filter script cannot be empty." + )); } } if !api.network.is_public_web_url(&tracker.url).await { - log::error!( - "Tracker URL must be either `http` or `https` and have a valid public reachable domain name: {}", - tracker.url - ); - anyhow::bail!( - "Tracker URL must be either `http` or `https` and have a valid public reachable domain name" - ); + bail!(SecutilsError::client( + "Tracker URL must be either `http` or `https` and have a valid public reachable domain name." + )); } if let Some(schedule) = &tracker.schedule { @@ -105,8 +101,10 @@ impl UtilsWebScrapingAction { let schedule = match Schedule::try_from(schedule.as_str()) { Ok(schedule) => schedule, Err(err) => { - log::error!("Failed to parse schedule `{}`: {:?}", schedule, err); - anyhow::bail!("Tracker schedule must be a valid cron expression"); + bail!(SecutilsError::client_with_root_cause( + anyhow!("Failed to parse schedule `{schedule}`: {err:?}") + .context("Tracker schedule must be a valid cron expression.") + )); } }; @@ -117,11 +115,11 @@ impl UtilsWebScrapingAction { for (index, occurrence) in next_occurrences.iter().enumerate().skip(1) { let interval = (*occurrence - next_occurrences[index - 1]).to_std()?; if interval < minimum_interval { - anyhow::bail!( - "Tracker schedule must have at least {} between occurrences, detected {}", + bail!(SecutilsError::client(format!( + "Tracker schedule must have at least {} between occurrences, detected {}.", format_duration(minimum_interval), format_duration(interval) - ); + ))); } } } @@ -427,7 +425,7 @@ mod tests { .validate(&api) .await, @r###" Err( - "Tracker URL must be either `http` or `https` and have a valid public reachable domain name", + "Tracker URL must be either `http` or `https` and have a valid public reachable domain name.", ) "###); @@ -452,7 +450,7 @@ mod tests { .validate(&api_with_local_network) .await, @r###" Err( - "Tracker URL must be either `http` or `https` and have a valid public reachable domain name", + "Tracker URL must be either `http` or `https` and have a valid public reachable domain name.", ) "###); @@ -470,7 +468,7 @@ mod tests { .validate(&api) .await, @r###" Err( - "Tracker schedule must have at least 1h between occurrences, detected 1m", + "Tracker schedule must have at least 1h between occurrences, detected 1m.", ) "###); @@ -481,7 +479,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker name cannot be empty", + "Tracker name cannot be empty.", ) "###); @@ -492,7 +490,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker name cannot be longer than 100 characters", + "Tracker name cannot be longer than 100 characters.", ) "###); @@ -501,7 +499,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker name cannot be empty", + "Tracker name cannot be empty.", ) "###); @@ -510,7 +508,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker name cannot be longer than 100 characters", + "Tracker name cannot be longer than 100 characters.", ) "###); @@ -519,7 +517,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker name cannot be empty", + "Tracker name cannot be empty.", ) "###); @@ -528,7 +526,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker name cannot be longer than 100 characters", + "Tracker name cannot be longer than 100 characters.", ) "###); @@ -540,7 +538,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker name cannot be empty", + "Tracker name cannot be empty.", ) "###); @@ -555,7 +553,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker name cannot be longer than 100 characters", + "Tracker name cannot be longer than 100 characters.", ) "###); @@ -570,7 +568,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker revisions count cannot be greater than 10", + "Tracker revisions count cannot be greater than 10.", ) "###); @@ -586,7 +584,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker delay cannot be greater than 60000ms", + "Tracker delay cannot be greater than 60000ms.", ) "###); @@ -604,7 +602,7 @@ mod tests { } .validate(&api).await, @r###" Err( - "Tracker resource filter script cannot be empty", + "Tracker resource filter script cannot be empty.", ) "###); diff --git a/src/utils/web_security/api_ext.rs b/src/utils/web_security/api_ext.rs index 6a2ade0..c4c9495 100644 --- a/src/utils/web_security/api_ext.rs +++ b/src/utils/web_security/api_ext.rs @@ -20,6 +20,7 @@ use reqwest::redirect::Policy as RedirectPolicy; use std::collections::BTreeMap; use time::OffsetDateTime; +/// API extension to work with web security utilities. pub struct WebSecurityApi<'a, DR: DnsResolver, ET: EmailTransport> { api: &'a Api, } diff --git a/src/utils/web_security/utils_web_security_action.rs b/src/utils/web_security/utils_web_security_action.rs index 819ab37..6ef0553 100644 --- a/src/utils/web_security/utils_web_security_action.rs +++ b/src/utils/web_security/utils_web_security_action.rs @@ -1,5 +1,6 @@ use crate::{ api::Api, + error::Error as SecutilsError, network::{DnsResolver, EmailTransport}, users::{ClientUserShare, SharedResource, User}, utils::{ @@ -8,7 +9,7 @@ use crate::{ ContentSecurityPolicySource, UtilsWebSecurityActionResult, }, }; -use anyhow::anyhow; +use anyhow::{anyhow, bail}; use serde::Deserialize; #[allow(clippy::enum_variant_names)] @@ -44,54 +45,52 @@ impl UtilsWebSecurityAction { &self, api: &Api, ) -> anyhow::Result<()> { + let assert_policy_name = |name: &str| -> Result<(), SecutilsError> { + if name.is_empty() { + return Err(SecutilsError::client("Policy name cannot be empty.")); + } + + if name.len() > MAX_UTILS_ENTITY_NAME_LENGTH { + return Err(SecutilsError::client(format!( + "Policy name cannot be longer than {} characters.", + MAX_UTILS_ENTITY_NAME_LENGTH + ))); + } + + Ok(()) + }; + match self { UtilsWebSecurityAction::SerializeContentSecurityPolicy { policy_name, .. } | UtilsWebSecurityAction::GetContentSecurityPolicy { policy_name } | UtilsWebSecurityAction::RemoveContentSecurityPolicy { policy_name } | UtilsWebSecurityAction::ShareContentSecurityPolicy { policy_name } | UtilsWebSecurityAction::UnshareContentSecurityPolicy { policy_name } => { - if policy_name.is_empty() { - anyhow::bail!("Policy name cannot be empty"); - } - - if policy_name.len() > MAX_UTILS_ENTITY_NAME_LENGTH { - anyhow::bail!( - "Policy name cannot be longer than {} characters", - MAX_UTILS_ENTITY_NAME_LENGTH - ); - } + assert_policy_name(policy_name)?; } UtilsWebSecurityAction::SaveContentSecurityPolicy { policy } => { if !policy.is_valid() { - anyhow::bail!("Policy is not valid"); + bail!(SecutilsError::client("Policy is not valid.")); } } UtilsWebSecurityAction::ImportContentSecurityPolicy { policy_name, import_type: source, } => { - if policy_name.is_empty() { - anyhow::bail!("Policy name cannot be empty"); - } - - if policy_name.len() > MAX_UTILS_ENTITY_NAME_LENGTH { - anyhow::bail!( - "Policy name cannot be longer than {} characters", - MAX_UTILS_ENTITY_NAME_LENGTH - ); - } + assert_policy_name(policy_name)?; match source { ContentSecurityPolicyImportType::Text { text } => { if text.is_empty() { - anyhow::bail!("Content security policy text to import source text cannot be empty"); + bail!(SecutilsError::client( + "Content security policy text to import source text cannot be empty." + )); } } ContentSecurityPolicyImportType::Url { url, .. } => { if !api.network.is_public_web_url(url).await { - log::error!("URL must be either `http` or `https` and have a valid public reachable domain name: {url}"); - anyhow::bail!( - "URL must be either `http` or `https` and have a valid public reachable domain name" + bail!( + SecutilsError::client(format!("URL must be either `http` or `https` and have a valid public reachable domain name: {url}.")) ); } } @@ -351,14 +350,14 @@ mod tests { for action in get_actions("".to_string()) { assert_eq!( action.validate(&api).await.map_err(|err| err.to_string()), - Err("Policy name cannot be empty".to_string()) + Err("Policy name cannot be empty.".to_string()) ); } for action in get_actions("a".repeat(101)) { assert_eq!( action.validate(&api).await.map_err(|err| err.to_string()), - Err("Policy name cannot be longer than 100 characters".to_string()) + Err("Policy name cannot be longer than 100 characters.".to_string()) ); } @@ -386,7 +385,7 @@ mod tests { .validate(&api) .await .map_err(|err| err.to_string()), - Err("Policy is not valid".to_string()) + Err("Policy is not valid.".to_string()) ); assert_eq!( @@ -399,7 +398,7 @@ mod tests { .validate(&api) .await .map_err(|err| err.to_string()), - Err("Policy name cannot be empty".to_string()) + Err("Policy name cannot be empty.".to_string()) ); assert_eq!( @@ -412,7 +411,7 @@ mod tests { .validate(&api) .await .map_err(|err| err.to_string()), - Err("Policy name cannot be longer than 100 characters".to_string()) + Err("Policy name cannot be longer than 100 characters.".to_string()) ); assert_eq!( @@ -425,7 +424,7 @@ mod tests { .validate(&api) .await .map_err(|err| err.to_string()), - Err("Content security policy text to import source text cannot be empty".to_string()) + Err("Content security policy text to import source text cannot be empty.".to_string()) ); let api_with_local_network = @@ -448,7 +447,7 @@ mod tests { .validate(&api_with_local_network) .await .map_err(|err| err.to_string()), - Err("URL must be either `http` or `https` and have a valid public reachable domain name".to_string()) + Err("URL must be either `http` or `https` and have a valid public reachable domain name: https://secutils.dev/my-page.".to_string()) ); assert!(UtilsWebSecurityAction::ImportContentSecurityPolicy { diff --git a/src/utils/webhooks/utils_webhooks_action.rs b/src/utils/webhooks/utils_webhooks_action.rs index 0da7c0d..985b0ac 100644 --- a/src/utils/webhooks/utils_webhooks_action.rs +++ b/src/utils/webhooks/utils_webhooks_action.rs @@ -1,9 +1,11 @@ use crate::{ api::Api, + error::Error as SecutilsError, network::{DnsResolver, EmailTransport}, users::User, utils::{AutoResponder, UtilsWebhooksActionResult}, }; +use anyhow::bail; use serde::Deserialize; #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] @@ -25,12 +27,12 @@ impl UtilsWebhooksAction { UtilsWebhooksAction::GetAutoRespondersRequests { responder_path } | UtilsWebhooksAction::RemoveAutoResponder { responder_path } => { if !AutoResponder::is_path_valid(responder_path) { - anyhow::bail!("Auto responder path is not valid"); + bail!(SecutilsError::client("Auto responder path is not valid.")); } } UtilsWebhooksAction::SaveAutoResponder { responder } => { if !responder.is_valid() { - anyhow::bail!("Auto responder is not valid"); + bail!(SecutilsError::client("Auto responder is not valid.")); } } } @@ -150,7 +152,7 @@ mod tests { } .validate(), @r###" Err( - "Auto responder path is not valid", + "Auto responder path is not valid.", ) "###); @@ -159,7 +161,7 @@ mod tests { } .validate(), @r###" Err( - "Auto responder path is not valid", + "Auto responder path is not valid.", ) "###); @@ -174,7 +176,7 @@ mod tests { } .validate(), @r###" Err( - "Auto responder path is not valid", + "Auto responder path is not valid.", ) "###); @@ -183,7 +185,7 @@ mod tests { } .validate(), @r###" Err( - "Auto responder path is not valid", + "Auto responder path is not valid.", ) "###); @@ -214,7 +216,7 @@ mod tests { } .validate(), @r###" Err( - "Auto responder is not valid", + "Auto responder is not valid.", ) "###); @@ -231,7 +233,7 @@ mod tests { } .validate(), @r###" Err( - "Auto responder is not valid", + "Auto responder is not valid.", ) "###); diff --git a/tools/api/utils/certificates_private_keys.http b/tools/api/utils/certificates_private_keys.http new file mode 100644 index 0000000..0e334bb --- /dev/null +++ b/tools/api/utils/certificates_private_keys.http @@ -0,0 +1,62 @@ +### Create private key (RSA, without passphrase). +POST {{host}}/api/utils/action +Authorization: {{api-credentials}} +Accept: application/json +Content-Type: application/json + +{ + "action": { + "type": "certificates", + "value": { + "type": "createPrivateKey", + "value": { "name": "pk", "alg": { "keyType": "rsa", "keySize": "1024" } } + } + } +} + +### Create private key (ed25519, with passphrase). +POST {{host}}/api/utils/action +Authorization: {{api-credentials}} +Accept: application/json +Content-Type: application/json + +{ + "action": { + "type": "certificates", + "value": { + "type": "createPrivateKey", + "value": { "name": "pk-ed25519", "alg": { "keyType": "ed25519" }, "passphrase": "123456" } + } + } +} + +### + +### Export private key. +POST {{host}}/api/utils/action +Authorization: {{api-credentials}} +Accept: application/json +Content-Type: application/json + +{ + "action": { + "type": "certificates", + "value": { + "type": "exportPrivateKey", + "value": { "keyName": "pk", "format": "pem" } + } + } +} + +### Get private keys. +POST {{host}}/api/utils/action +Authorization: {{api-credentials}} +Accept: application/json +Content-Type: application/json + +{ + "action": { + "type": "certificates", + "value": { "type": "getPrivateKeys" } + } +}