891 lines
30 KiB
Rust
891 lines
30 KiB
Rust
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,
|
|
tls_verify: session.imap_tls_verify,
|
|
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
|
|
&& 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(
|
|
crate::queries::sqlite::queries::routes_pgp::Q_001,
|
|
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
|
|
&& !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())
|
|
}
|