commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -0,0 +1,884 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::session::SessionState;
|
||||
use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::db::pgp::{PgpKeyRecord, PgpPreferences};
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenerateRequest {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub passphrase: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportRequest {
|
||||
#[serde(default)]
|
||||
pub key_data: Option<String>,
|
||||
#[serde(default)]
|
||||
pub key_data_base64: Option<String>,
|
||||
#[serde(default = "default_trust")]
|
||||
pub trust: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExportQuery {
|
||||
#[serde(default)]
|
||||
pub secret: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TrustRequest {
|
||||
pub trust: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DiscoverRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecipientsRequest {
|
||||
pub emails: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecipientStatus {
|
||||
pub email: String,
|
||||
pub has_key: bool,
|
||||
pub fingerprint: Option<String>,
|
||||
pub trust: Option<String>,
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyRequest {
|
||||
pub data: String,
|
||||
pub signature: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyResponse {
|
||||
pub verified: bool,
|
||||
pub plaintext: Option<String>,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecryptRequest {
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub passphrase: String,
|
||||
pub fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiscoverResponse {
|
||||
pub found: bool,
|
||||
pub source: Option<String>,
|
||||
pub key: Option<PgpKeyRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessMessageRequest {
|
||||
#[serde(default)]
|
||||
pub passphrase: String,
|
||||
pub fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TestPassphraseRequest {
|
||||
#[serde(default)]
|
||||
pub passphrase: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChangePassphraseRequest {
|
||||
#[serde(default)]
|
||||
pub current_passphrase: String,
|
||||
#[serde(default)]
|
||||
pub new_passphrase: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TestPassphraseResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub async fn process_message(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||
Path((folder, uid)): Path<(String, u32)>,
|
||||
Json(request): Json<ProcessMessageRequest>,
|
||||
) -> Result<Json<crate::pgp::ProcessedMessage>, AppError> {
|
||||
if request.passphrase.len() > 1024 {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP passphrase is too long".to_string(),
|
||||
));
|
||||
}
|
||||
let creds = ImapCredentials {
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
let raw = imap_client
|
||||
.fetch_raw_message(&creds, &folder, uid)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
|
||||
if raw.len() > 50 * 1024 * 1024 {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP message is too large to process".to_string(),
|
||||
));
|
||||
}
|
||||
if !crate::pgp::looks_like_openpgp(&raw) {
|
||||
return Ok(Json(crate::pgp::ProcessedMessage {
|
||||
pgp: false,
|
||||
encrypted: false,
|
||||
decrypted: false,
|
||||
signed: false,
|
||||
verified: false,
|
||||
signer_fingerprint: None,
|
||||
signer_email: None,
|
||||
signer_trust: None,
|
||||
text: None,
|
||||
html: None,
|
||||
attachments: vec![],
|
||||
}));
|
||||
}
|
||||
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let requested = request
|
||||
.fingerprint
|
||||
.map(|value| db::pgp::normalize_fingerprint(&value));
|
||||
let secret_rows = database
|
||||
.run(move |conn| {
|
||||
if let Some(fp) = requested {
|
||||
Ok(db::pgp::get_key(conn, &fp)?
|
||||
.into_iter()
|
||||
.filter(|key| key.secret_key_path.is_some())
|
||||
.collect::<Vec<_>>())
|
||||
} else {
|
||||
db::pgp::list_secret_keys(conn)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
let public_keys = database
|
||||
.run(|conn| {
|
||||
Ok(db::pgp::list_keys(conn)?
|
||||
.into_iter()
|
||||
.map(|key| (key.fingerprint, key.public_key_armor))
|
||||
.collect::<Vec<_>>())
|
||||
})
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
let passphrase = request.passphrase;
|
||||
let mut result = tokio::task::spawn_blocking(move || {
|
||||
let mut secrets = Vec::new();
|
||||
for key in secret_rows {
|
||||
if let Some(path) = key.secret_key_path {
|
||||
if let Ok(armor) = crate::pgp::read_secret_key(&path) {
|
||||
secrets.push(armor);
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::pgp::process_message(&raw, &secrets, &passphrase, &public_keys)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP message processing task failed: {e}")))?
|
||||
.map_err(AppError::BadRequest)?;
|
||||
if let Some(fingerprint) = result.signer_fingerprint.clone() {
|
||||
let lookup = db::pgp::normalize_fingerprint(&fingerprint);
|
||||
if let Some(key) = database
|
||||
.run(move |conn| db::pgp::get_key(conn, &lookup))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?
|
||||
{
|
||||
if !key.record.email.trim().is_empty() {
|
||||
result.signer_email = Some(key.record.email.clone());
|
||||
}
|
||||
result.signer_trust = Some(key.record.trust.clone());
|
||||
}
|
||||
}
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
fn default_trust() -> String {
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
pub async fn list_keys(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
) -> Result<Json<Vec<PgpKeyRecord>>, AppError> {
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let keys = database
|
||||
.run(db::pgp::list_keys)
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
Ok(Json(keys))
|
||||
}
|
||||
|
||||
pub async fn generate_key(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Json(request): Json<GenerateRequest>,
|
||||
) -> Result<Json<PgpKeyRecord>, AppError> {
|
||||
validate_email(&request.email)?;
|
||||
if request.passphrase.len() > 1024 {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP passphrase is too long".to_string(),
|
||||
));
|
||||
}
|
||||
let name = request.name;
|
||||
let email = request.email.trim().to_ascii_lowercase();
|
||||
let passphrase = request.passphrase;
|
||||
let material =
|
||||
tokio::task::spawn_blocking(move || crate::pgp::generate_key(&name, &email, &passphrase))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP key generation task failed: {e}")))?
|
||||
.map_err(AppError::BadRequest)?;
|
||||
store_material(&session, &config, material, "generated", "verified").await
|
||||
}
|
||||
|
||||
pub async fn import_key(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Json(request): Json<ImportRequest>,
|
||||
) -> Result<Json<PgpKeyRecord>, AppError> {
|
||||
validate_trust(&request.trust)?;
|
||||
let input = match (request.key_data, request.key_data_base64) {
|
||||
(Some(text), None) => {
|
||||
if text.len() > config.pgp_max_key_bytes.saturating_mul(2) {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP key input is too large".to_string(),
|
||||
));
|
||||
}
|
||||
text.into_bytes()
|
||||
}
|
||||
(None, Some(encoded)) => {
|
||||
let max_encoded = config
|
||||
.pgp_max_key_bytes
|
||||
.saturating_mul(4)
|
||||
.saturating_div(3)
|
||||
.saturating_add(16);
|
||||
if encoded.len() > max_encoded {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP key input is too large".to_string(),
|
||||
));
|
||||
}
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded.as_bytes())
|
||||
.map_err(|_| AppError::BadRequest("Invalid base64 OpenPGP key data".to_string()))?;
|
||||
if decoded.len() > config.pgp_max_key_bytes {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP key input is too large".to_string(),
|
||||
));
|
||||
}
|
||||
decoded
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Provide either keyData or keyDataBase64, not both".to_string(),
|
||||
));
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP key data is required".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let material = tokio::task::spawn_blocking(move || crate::pgp::import_key(&input))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP import task failed: {e}")))?
|
||||
.map_err(AppError::BadRequest)?;
|
||||
store_material(&session, &config, material, "import", &request.trust).await
|
||||
}
|
||||
|
||||
pub async fn export_key(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
Query(query): Query<ExportQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let normalized = db::pgp::normalize_fingerprint(&fingerprint);
|
||||
let key = database
|
||||
.run(move |conn| db::pgp::get_key(conn, &normalized))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?
|
||||
.ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?;
|
||||
|
||||
let (body, suffix) = if query.secret {
|
||||
if !config.pgp_allow_private_export {
|
||||
return Err(AppError::Forbidden(
|
||||
"Private OpenPGP key export is disabled by administrator policy".to_string(),
|
||||
));
|
||||
}
|
||||
let path = key
|
||||
.secret_key_path
|
||||
.ok_or_else(|| AppError::BadRequest("This key has no private material".to_string()))?;
|
||||
let body = tokio::task::spawn_blocking(move || crate::pgp::read_secret_key(&path))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP export task failed: {e}")))?
|
||||
.map_err(AppError::InternalError)?;
|
||||
(body, "private")
|
||||
} else {
|
||||
(key.record.public_key_armor, "public")
|
||||
};
|
||||
|
||||
let filename = format!("openpgp-{}-{suffix}.asc", key.record.key_id);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"content-type",
|
||||
HeaderValue::from_static("application/pgp-keys; charset=utf-8"),
|
||||
);
|
||||
headers.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
headers.insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
"content-disposition",
|
||||
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
|
||||
.map_err(|e| AppError::InternalError(format!("Invalid export filename: {e}")))?,
|
||||
);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
pub async fn test_passphrase(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
Json(request): Json<TestPassphraseRequest>,
|
||||
) -> Result<Json<TestPassphraseResponse>, AppError> {
|
||||
if request.passphrase.len() > 1024 {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP passphrase is too long".to_string(),
|
||||
));
|
||||
}
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let normalized = db::pgp::normalize_fingerprint(&fingerprint);
|
||||
let key = database
|
||||
.run(move |conn| db::pgp::get_key(conn, &normalized))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?
|
||||
.ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?;
|
||||
let path = key
|
||||
.secret_key_path
|
||||
.ok_or_else(|| AppError::BadRequest("This key has no private material".to_string()))?;
|
||||
let passphrase = request.passphrase;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let secret = crate::pgp::read_secret_key(&path)?;
|
||||
crate::pgp::test_secret_key_passphrase(&secret, &passphrase)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP passphrase test task failed: {e}")))?
|
||||
.map_err(|_| {
|
||||
AppError::BadRequest(
|
||||
"OpenPGP private key could not be unlocked with that passphrase".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(TestPassphraseResponse { ok: true }))
|
||||
}
|
||||
|
||||
pub async fn change_passphrase(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
Json(request): Json<ChangePassphraseRequest>,
|
||||
) -> Result<Json<TestPassphraseResponse>, AppError> {
|
||||
if request.current_passphrase.len() > 1024 || request.new_passphrase.len() > 1024 {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP passphrase is too long".to_string(),
|
||||
));
|
||||
}
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let normalized = db::pgp::normalize_fingerprint(&fingerprint);
|
||||
let lookup = normalized.clone();
|
||||
let key = database
|
||||
.run(move |conn| db::pgp::get_key(conn, &lookup))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?
|
||||
.ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?;
|
||||
let path = key
|
||||
.secret_key_path
|
||||
.ok_or_else(|| AppError::BadRequest("This key has no private material".to_string()))?;
|
||||
let data_dir = config.data_dir.clone();
|
||||
let user_hash = session.user_hash.clone();
|
||||
let gpg_binary = std::env::var("RUSTBOX_PGP_GPG_BINARY").unwrap_or_else(|_| "gpg".to_string());
|
||||
let current = request.current_passphrase;
|
||||
let new_passphrase = request.new_passphrase;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::pgp::change_secret_key_passphrase(
|
||||
&data_dir,
|
||||
&user_hash,
|
||||
&normalized,
|
||||
&path,
|
||||
¤t,
|
||||
&new_passphrase,
|
||||
&gpg_binary,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP passphrase-change task failed: {e}")))?
|
||||
.map_err(|error| {
|
||||
if error.contains("requires GnuPG") || error.contains("start GnuPG") {
|
||||
AppError::ServiceUnavailable(error)
|
||||
} else {
|
||||
AppError::BadRequest(error)
|
||||
}
|
||||
})?;
|
||||
Ok(Json(TestPassphraseResponse { ok: true }))
|
||||
}
|
||||
|
||||
pub async fn delete_key(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let normalized = db::pgp::normalize_fingerprint(&fingerprint);
|
||||
let lookup = normalized.clone();
|
||||
let key = database
|
||||
.run(move |conn| db::pgp::get_key(conn, &lookup))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
let Some(key) = key else {
|
||||
return Err(AppError::NotFound("OpenPGP key not found".to_string()));
|
||||
};
|
||||
|
||||
let delete_fp = normalized.clone();
|
||||
database.run(move |conn| {
|
||||
conn.execute("UPDATE identities SET pgp_fingerprint=NULL,pgp_sign_default=0,pgp_encrypt_default=0,pgp_inline_default=0 WHERE replace(upper(COALESCE(pgp_fingerprint,'')),' ','')=?1", rusqlite::params![delete_fp])
|
||||
.map_err(|e| format!("Failed to detach OpenPGP key from identities: {e}"))?;
|
||||
db::pgp::delete_key(conn, &normalized)
|
||||
}).await.map_err(AppError::InternalError)?;
|
||||
|
||||
if let Some(path) = key.secret_key_path {
|
||||
tokio::task::spawn_blocking(move || crate::pgp::delete_secret_key(&path))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP delete task failed: {e}")))?
|
||||
.map_err(AppError::InternalError)?;
|
||||
}
|
||||
Ok(axum::http::StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
pub async fn set_trust(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path(fingerprint): Path<String>,
|
||||
Json(request): Json<TrustRequest>,
|
||||
) -> Result<Json<PgpKeyRecord>, AppError> {
|
||||
validate_trust(&request.trust)?;
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let normalized = db::pgp::normalize_fingerprint(&fingerprint);
|
||||
let trust = request.trust;
|
||||
let update_fp = normalized.clone();
|
||||
let changed = database
|
||||
.run(move |conn| db::pgp::set_trust(conn, &update_fp, &trust))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
if !changed {
|
||||
return Err(AppError::NotFound("OpenPGP key not found".to_string()));
|
||||
}
|
||||
let key = database
|
||||
.run(move |conn| db::pgp::get_key(conn, &normalized))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?
|
||||
.ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?;
|
||||
Ok(Json(key.record))
|
||||
}
|
||||
|
||||
pub async fn get_preferences(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
) -> Result<Json<PgpPreferences>, AppError> {
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let prefs = database
|
||||
.run(db::pgp::get_preferences)
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
Ok(Json(prefs))
|
||||
}
|
||||
|
||||
pub async fn update_preferences(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Json(mut prefs): Json<PgpPreferences>,
|
||||
) -> Result<Json<PgpPreferences>, AppError> {
|
||||
if !config.pgp_wkd_enabled {
|
||||
prefs.wkd_enabled = false;
|
||||
}
|
||||
if !config.pgp_hkp_enabled {
|
||||
prefs.hkp_enabled = false;
|
||||
}
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let updated = database
|
||||
.run(move |conn| db::pgp::update_preferences(conn, &prefs))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
pub async fn discover_key(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Json(request): Json<DiscoverRequest>,
|
||||
) -> Result<Json<DiscoverResponse>, AppError> {
|
||||
validate_email(&request.email)?;
|
||||
let email = request.email.trim().to_ascii_lowercase();
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let prefs = database
|
||||
.run(db::pgp::get_preferences)
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
|
||||
let mut discovered = None;
|
||||
let mut source = None;
|
||||
if prefs.wkd_enabled && config.pgp_wkd_enabled {
|
||||
match crate::pgp::discovery::discover_wkd(
|
||||
&email,
|
||||
config.pgp_network_timeout_secs,
|
||||
config.pgp_max_key_bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(bytes)) => {
|
||||
discovered = Some(bytes);
|
||||
source = Some("wkd".to_string());
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => tracing::debug!(email, error = %error, "WKD key lookup failed"),
|
||||
}
|
||||
}
|
||||
if discovered.is_none() && prefs.hkp_enabled && config.pgp_hkp_enabled {
|
||||
match crate::pgp::discovery::discover_hkp(
|
||||
&config.pgp_hkp_url,
|
||||
&email,
|
||||
config.pgp_network_timeout_secs,
|
||||
config.pgp_max_key_bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(bytes)) => {
|
||||
discovered = Some(bytes);
|
||||
source = Some("hkp".to_string());
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => tracing::debug!(email, error = %error, "HKP key lookup failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let Some(bytes) = discovered else {
|
||||
return Ok(Json(DiscoverResponse {
|
||||
found: false,
|
||||
source: None,
|
||||
key: None,
|
||||
}));
|
||||
};
|
||||
let material = tokio::task::spawn_blocking(move || crate::pgp::import_key(&bytes))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP discovery import task failed: {e}")))?
|
||||
.map_err(AppError::BadRequest)?;
|
||||
if !material.email.is_empty() && !material.email.eq_ignore_ascii_case(&email) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Discovered OpenPGP key does not match the requested email address".to_string(),
|
||||
));
|
||||
}
|
||||
let source_value = source.clone().unwrap_or_else(|| "discovery".to_string());
|
||||
let stored = store_material_raw(&session, &config, material, &source_value, "unknown").await?;
|
||||
Ok(Json(DiscoverResponse {
|
||||
found: true,
|
||||
source,
|
||||
key: Some(stored),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn recipient_status(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Json(request): Json<RecipientsRequest>,
|
||||
) -> Result<Json<Vec<RecipientStatus>>, AppError> {
|
||||
if request.emails.len() > 200 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Too many OpenPGP recipients".to_string(),
|
||||
));
|
||||
}
|
||||
let emails: Vec<String> = request
|
||||
.emails
|
||||
.into_iter()
|
||||
.map(|value| mailbox_email(&value))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let result = database
|
||||
.run(move |conn| {
|
||||
let mut statuses = Vec::with_capacity(emails.len());
|
||||
for email in emails {
|
||||
let key = db::pgp::find_keys_by_email(conn, &email)?
|
||||
.into_iter()
|
||||
.next();
|
||||
statuses.push(match key {
|
||||
Some(key) => RecipientStatus {
|
||||
email,
|
||||
has_key: true,
|
||||
fingerprint: Some(key.fingerprint),
|
||||
trust: Some(key.trust),
|
||||
source: Some(key.source),
|
||||
},
|
||||
None => RecipientStatus {
|
||||
email,
|
||||
has_key: false,
|
||||
fingerprint: None,
|
||||
trust: None,
|
||||
source: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(statuses)
|
||||
})
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn verify(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Json(request): Json<VerifyRequest>,
|
||||
) -> Result<Json<VerifyResponse>, AppError> {
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let fingerprint = db::pgp::normalize_fingerprint(&request.fingerprint);
|
||||
let lookup = fingerprint.clone();
|
||||
let key = database
|
||||
.run(move |conn| db::pgp::get_key(conn, &lookup))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?
|
||||
.ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?;
|
||||
let public = key.record.public_key_armor;
|
||||
let data = request.data;
|
||||
let signature = request.signature;
|
||||
let message = request.message;
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
if let Some(signature) = signature {
|
||||
crate::pgp::verify_detached(data.as_bytes(), &signature, &public)?;
|
||||
Ok::<Option<String>, String>(None)
|
||||
} else if let Some(message) = message {
|
||||
crate::pgp::verify_inline(&message, &public).map(Some)
|
||||
} else {
|
||||
Err("Either signature or inline message is required".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP verification task failed: {e}")))?
|
||||
.map_err(AppError::BadRequest)?;
|
||||
Ok(Json(VerifyResponse {
|
||||
verified: true,
|
||||
plaintext: output,
|
||||
fingerprint,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn decrypt(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Json(request): Json<DecryptRequest>,
|
||||
) -> Result<Json<crate::pgp::DecryptResult>, AppError> {
|
||||
if request.passphrase.len() > 1024 {
|
||||
return Err(AppError::BadRequest(
|
||||
"OpenPGP passphrase is too long".to_string(),
|
||||
));
|
||||
}
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let requested = request
|
||||
.fingerprint
|
||||
.map(|value| db::pgp::normalize_fingerprint(&value));
|
||||
let candidates = database
|
||||
.run(move |conn| {
|
||||
if let Some(fingerprint) = requested {
|
||||
Ok(db::pgp::get_key(conn, &fingerprint)?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>())
|
||||
} else {
|
||||
db::pgp::list_secret_keys(conn)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
if candidates.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"No OpenPGP private key is available".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let public_armors = database
|
||||
.run(|conn| {
|
||||
Ok(db::pgp::list_keys(conn)?
|
||||
.into_iter()
|
||||
.map(|key| key.public_key_armor)
|
||||
.collect::<Vec<_>>())
|
||||
})
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
let message = request.message;
|
||||
let passphrase = request.passphrase;
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut last_error = "No OpenPGP private key could decrypt this message".to_string();
|
||||
for candidate in candidates {
|
||||
let Some(path) = candidate.secret_key_path else {
|
||||
continue;
|
||||
};
|
||||
let secret = match crate::pgp::read_secret_key(&path) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
last_error = error;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match crate::pgp::decrypt_inline(&message, &secret, &passphrase, &public_armors) {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(error) => last_error = error,
|
||||
}
|
||||
}
|
||||
Err(last_error)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP decryption task failed: {e}")))?
|
||||
.map_err(AppError::BadRequest)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn store_material(
|
||||
session: &SessionState,
|
||||
config: &AppConfig,
|
||||
material: crate::pgp::ImportedKey,
|
||||
source: &str,
|
||||
trust: &str,
|
||||
) -> Result<Json<PgpKeyRecord>, AppError> {
|
||||
Ok(Json(
|
||||
store_material_raw(session, config, material, source, trust).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn store_material_raw(
|
||||
session: &SessionState,
|
||||
config: &AppConfig,
|
||||
material: crate::pgp::ImportedKey,
|
||||
source: &str,
|
||||
trust: &str,
|
||||
) -> Result<PgpKeyRecord, AppError> {
|
||||
validate_trust(trust)?;
|
||||
let secret_store = if let Some(secret_armor) = material.secret_armor.clone() {
|
||||
let data_dir = config.data_dir.clone();
|
||||
let user_hash = session.user_hash.clone();
|
||||
let fingerprint = material.fingerprint.clone();
|
||||
Some(
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let target = crate::pgp::private_key_dir(&data_dir, &user_hash)
|
||||
.join(format!("{fingerprint}.asc"));
|
||||
let existed = target.exists();
|
||||
crate::pgp::store_secret_key(&data_dir, &user_hash, &fingerprint, &secret_armor)
|
||||
.map(|path| (path, existed))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("PGP key storage task failed: {e}")))?
|
||||
.map_err(AppError::InternalError)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let secret_path = secret_store.as_ref().map(|(path, _)| path.clone());
|
||||
|
||||
let record = PgpKeyRecord {
|
||||
fingerprint: material.fingerprint,
|
||||
key_id: material.key_id,
|
||||
email: material.email,
|
||||
user_id: material.user_id,
|
||||
public_key_armor: material.public_armor,
|
||||
has_secret: secret_path.is_some(),
|
||||
trust: trust.to_string(),
|
||||
source: source.to_string(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
};
|
||||
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash);
|
||||
let stored = record.clone();
|
||||
let path = secret_path.clone();
|
||||
if let Err(error) = database
|
||||
.run(move |conn| db::pgp::upsert_key(conn, &stored, path.as_deref()))
|
||||
.await
|
||||
{
|
||||
if let Some((path, existed)) = secret_store {
|
||||
if !existed {
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || crate::pgp::delete_secret_key(&path)).await;
|
||||
}
|
||||
}
|
||||
return Err(AppError::InternalError(error));
|
||||
}
|
||||
let lookup = record.fingerprint.clone();
|
||||
let key = database
|
||||
.run(move |conn| db::pgp::get_key(conn, &lookup))
|
||||
.await
|
||||
.map_err(AppError::InternalError)?
|
||||
.ok_or_else(|| AppError::InternalError("Stored OpenPGP key disappeared".to_string()))?;
|
||||
Ok(key.record)
|
||||
}
|
||||
|
||||
fn validate_trust(value: &str) -> Result<(), AppError> {
|
||||
if matches!(value, "unknown" | "untrusted" | "trusted" | "verified") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::BadRequest(
|
||||
"Trust must be unknown, untrusted, trusted, or verified".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_email(value: &str) -> Result<(), AppError> {
|
||||
let email = value.trim();
|
||||
if email.len() > 320
|
||||
|| !email.contains('@')
|
||||
|| email
|
||||
.chars()
|
||||
.any(|ch| matches!(ch, '\r' | '\n' | '<' | '>'))
|
||||
{
|
||||
return Err(AppError::BadRequest("Invalid email address".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mailbox_email(value: &str) -> Result<String, AppError> {
|
||||
let mailbox: lettre::message::Mailbox = value
|
||||
.parse()
|
||||
.map_err(|_| AppError::BadRequest(format!("Invalid email address: {value}")))?;
|
||||
Ok(mailbox.email.to_string().to_ascii_lowercase())
|
||||
}
|
||||
Reference in new issue
Block a user