mirror of
https://github.com/bitwarden/server
synced 2026-01-28 23:36:12 +00:00
Reader API implementation
This commit is contained in:
15
akd/Cargo.lock
generated
15
akd/Cargo.lock
generated
@@ -62,6 +62,7 @@ dependencies = [
|
||||
"hex",
|
||||
"log",
|
||||
"protobuf",
|
||||
"serde",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -79,6 +80,8 @@ dependencies = [
|
||||
"protobuf",
|
||||
"protobuf-codegen",
|
||||
"protobuf-parse",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"tokio",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -953,6 +956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"serde",
|
||||
"signature",
|
||||
]
|
||||
|
||||
@@ -2255,6 +2259,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"bitwarden-akd-configuration",
|
||||
"bitwarden-encoding",
|
||||
"chrono",
|
||||
"common",
|
||||
"config",
|
||||
@@ -2531,6 +2536,16 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
|
||||
@@ -91,7 +91,7 @@ impl VrfKeyConfig {
|
||||
Ok(blake3::hash(&root_key_bytes).as_bytes().to_vec())
|
||||
}
|
||||
|
||||
pub fn root_key_type(&self) -> VrfRootKeyType {
|
||||
pub(crate) fn root_key_type(&self) -> VrfRootKeyType {
|
||||
match self {
|
||||
#[cfg(test)]
|
||||
VrfKeyConfig::ConstantVrfKey => VrfRootKeyType::None,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use akd::Directory;
|
||||
use akd::{directory::ReadOnlyDirectory, Directory};
|
||||
use akd_storage::{AkdDatabase, VrfKeyDatabase};
|
||||
use bitwarden_akd_configuration::BitwardenV1Configuration;
|
||||
|
||||
pub type BitAkdDirectory = Directory<BitwardenV1Configuration, AkdDatabase, VrfKeyDatabase>;
|
||||
pub type ReadOnlyBitAkdDirectory =
|
||||
ReadOnlyDirectory<BitwardenV1Configuration, AkdDatabase, VrfKeyDatabase>;
|
||||
|
||||
@@ -10,7 +10,7 @@ keywords.workspace = true
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
akd = { workspace = true }
|
||||
akd = { workspace = true, features = ["serde_serialization"] }
|
||||
akd_storage = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
bitwarden-akd-configuration = { workspace = true }
|
||||
@@ -20,6 +20,7 @@ config = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
bitwarden-encoding = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use akd::directory::ReadOnlyDirectory;
|
||||
use akd_storage::{AkdDatabase, ReadOnlyPublishQueueType, VrfKeyDatabase};
|
||||
use akd_storage::{AkdDatabase, VrfKeyDatabase};
|
||||
use anyhow::{Context, Result};
|
||||
use axum::Router;
|
||||
use bitwarden_akd_configuration::BitwardenV1Configuration;
|
||||
@@ -10,12 +10,14 @@ mod config;
|
||||
mod routes;
|
||||
|
||||
pub use crate::config::ApplicationConfig;
|
||||
pub use routes::response_types;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
// Add any shared state here, e.g., database connections
|
||||
directory: ReadOnlyDirectory<BitwardenV1Configuration, AkdDatabase, VrfKeyDatabase>,
|
||||
publish_queue: ReadOnlyPublishQueueType,
|
||||
// TODO: use this to allow for unique failures for lookup and key history requests that have pending updates
|
||||
// publish_queue: ReadOnlyPublishQueueType,
|
||||
}
|
||||
|
||||
#[instrument(skip_all, name = "reader_start")]
|
||||
@@ -23,7 +25,7 @@ pub async fn start(
|
||||
config: ApplicationConfig,
|
||||
shutdown_rx: &Receiver<()>,
|
||||
) -> Result<tokio::task::JoinHandle<Result<()>>> {
|
||||
let (directory, _, publish_queue) = config
|
||||
let (directory, _, _) = config
|
||||
.storage
|
||||
.initialize_readonly_directory::<BitwardenV1Configuration>()
|
||||
.await
|
||||
@@ -34,14 +36,14 @@ pub async fn start(
|
||||
let handle = tokio::spawn(async move {
|
||||
let app_state = AppState {
|
||||
directory: directory,
|
||||
publish_queue: publish_queue,
|
||||
// publish_queue: publish_queue,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.merge(crate::routes::api_routes())
|
||||
.with_state(app_state);
|
||||
|
||||
let listener = TcpListener::bind((&config.socket_address()))
|
||||
let listener = TcpListener::bind(&config.socket_address())
|
||||
.await
|
||||
.context("Socket bind failed")?;
|
||||
info!(
|
||||
|
||||
36
akd/crates/reader/src/routes/audit.rs
Normal file
36
akd/crates/reader/src/routes/audit.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
use crate::{routes::Response, AppState};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuditRequest {
|
||||
pub start_epoch: u64,
|
||||
pub end_epoch: u64,
|
||||
}
|
||||
|
||||
pub type AuditData = akd::AppendOnlyProof;
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn audit_handler(
|
||||
State(AppState { directory, .. }): State<AppState>,
|
||||
Json(AuditRequest {
|
||||
start_epoch,
|
||||
end_epoch,
|
||||
}): Json<AuditRequest>,
|
||||
) -> (StatusCode, Json<Response<AuditData>>) {
|
||||
info!(
|
||||
start_epoch,
|
||||
end_epoch, "Handling epoch audit request request"
|
||||
);
|
||||
let audit_proof = directory.audit(start_epoch, end_epoch).await;
|
||||
|
||||
match audit_proof {
|
||||
Ok(audit_proof) => (StatusCode::OK, Json(Response::success(audit_proof))),
|
||||
Err(e) => {
|
||||
error!(err = ?e, "Failed to perform epoch audit");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(Response::fail(e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
47
akd/crates/reader/src/routes/batch_lookup.rs
Normal file
47
akd/crates/reader/src/routes/batch_lookup.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
use crate::{
|
||||
routes::{get_epoch_hash::EpochData, lookup::AkdLabelB64, Response},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BatchLookupRequest {
|
||||
/// An array of labels to look up. Each label is encoded as base64.
|
||||
pub labels_b64: Vec<AkdLabelB64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BatchLookupData {
|
||||
pub lookup_proofs: Vec<akd::LookupProof>,
|
||||
pub epoch_data: EpochData,
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn batch_lookup_handler(
|
||||
State(AppState { directory, .. }): State<AppState>,
|
||||
Json(BatchLookupRequest { labels_b64 }): Json<BatchLookupRequest>,
|
||||
) -> (StatusCode, Json<Response<BatchLookupData>>) {
|
||||
info!("Handling get public key request");
|
||||
let labels = labels_b64
|
||||
.into_iter()
|
||||
.map(|label_b64| label_b64.into())
|
||||
.collect::<Vec<akd::AkdLabel>>();
|
||||
let lookup_proofs = directory.batch_lookup(&labels).await;
|
||||
|
||||
match lookup_proofs {
|
||||
Ok((lookup_proofs, epoch_hash)) => (
|
||||
StatusCode::OK,
|
||||
Json(Response::success(BatchLookupData {
|
||||
lookup_proofs,
|
||||
epoch_data: epoch_hash.into(),
|
||||
})),
|
||||
),
|
||||
Err(e) => {
|
||||
error!(err = ?e, "Failed to get AKD public key");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(Response::fail(e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
40
akd/crates/reader/src/routes/get_epoch_hash.rs
Normal file
40
akd/crates/reader/src/routes/get_epoch_hash.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use akd::EpochHash;
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
use crate::{routes::Response, AppState};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EpochData {
|
||||
pub epoch: u64,
|
||||
pub epoch_hash_b64: bitwarden_encoding::B64,
|
||||
}
|
||||
|
||||
impl From<EpochHash> for EpochData {
|
||||
fn from(epoch_hash: EpochHash) -> Self {
|
||||
EpochData {
|
||||
epoch: epoch_hash.0,
|
||||
epoch_hash_b64: bitwarden_encoding::B64::from(epoch_hash.1.as_ref()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn get_epoch_hash_handler(
|
||||
State(AppState { directory, .. }): State<AppState>,
|
||||
) -> (StatusCode, Json<Response<EpochData>>) {
|
||||
info!("Handling get epoch hash request");
|
||||
let epoch_hash = directory.get_epoch_hash().await;
|
||||
|
||||
match epoch_hash {
|
||||
Ok(epoch_hash) => (
|
||||
StatusCode::OK,
|
||||
Json(Response::<EpochData>::success(epoch_hash.into())),
|
||||
),
|
||||
Err(e) => {
|
||||
error!(err = ?e, "Failed to get current AKD epoch hash");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(Response::fail(e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
26
akd/crates/reader/src/routes/get_public_key.rs
Normal file
26
akd/crates/reader/src/routes/get_public_key.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
use crate::{routes::Response, AppState};
|
||||
|
||||
/// Public key encoded as a base64 string
|
||||
pub type PublicKeyData = bitwarden_encoding::B64;
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn get_public_key_handler(
|
||||
State(AppState { directory, .. }): State<AppState>,
|
||||
) -> (StatusCode, Json<Response<PublicKeyData>>) {
|
||||
info!("Handling get public key request");
|
||||
let public_key = directory.get_public_key().await;
|
||||
|
||||
match public_key {
|
||||
Ok(public_key) => (
|
||||
StatusCode::OK,
|
||||
Json(Response::success(public_key.as_ref().into())),
|
||||
),
|
||||
Err(e) => {
|
||||
error!(err = ?e, "Failed to get AKD public key");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(Response::fail(e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
use axum::Json;
|
||||
use axum::{http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::routes::Response;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServerHealth {
|
||||
pub struct HealthData {
|
||||
time: String,
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn health_handler() -> Json<ServerHealth> {
|
||||
pub async fn health_handler() -> (StatusCode, Json<Response<HealthData>>) {
|
||||
info!("Handling server health request");
|
||||
|
||||
let time = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
Json(ServerHealth { time })
|
||||
(StatusCode::OK, Json(Response::success(HealthData { time })))
|
||||
}
|
||||
|
||||
70
akd/crates/reader/src/routes/key_history.rs
Normal file
70
akd/crates/reader/src/routes/key_history.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
use crate::{
|
||||
routes::{get_epoch_hash::EpochData, Response},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct KeyHistoryRequest {
|
||||
/// the label to look up encoded as an uppercase hex string
|
||||
pub label: akd::AkdLabel,
|
||||
pub history_params: HistoryParams,
|
||||
}
|
||||
|
||||
/// The parameters that dictate how much of the history proof to return to the consumer
|
||||
/// (either a complete history, or some limited form).
|
||||
#[derive(Copy, Clone, Serialize, Deserialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum HistoryParams {
|
||||
/// Returns a complete history for a label
|
||||
Complete,
|
||||
/// Returns up to the most recent N updates for a label
|
||||
MostRecent(usize),
|
||||
/// Returns all updates since a specified epoch (inclusive)
|
||||
SinceEpoch(u64),
|
||||
}
|
||||
|
||||
impl From<HistoryParams> for akd::HistoryParams {
|
||||
fn from(params: HistoryParams) -> Self {
|
||||
match params {
|
||||
HistoryParams::Complete => akd::HistoryParams::Complete,
|
||||
HistoryParams::MostRecent(n) => akd::HistoryParams::MostRecent(n),
|
||||
HistoryParams::SinceEpoch(epoch) => akd::HistoryParams::SinceEpoch(epoch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct HistoryData {
|
||||
pub history_proof: akd::HistoryProof,
|
||||
pub epoch_data: EpochData,
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn key_history_handler(
|
||||
State(AppState { directory, .. }): State<AppState>,
|
||||
Json(KeyHistoryRequest {
|
||||
label,
|
||||
history_params,
|
||||
}): Json<KeyHistoryRequest>,
|
||||
) -> (StatusCode, Json<Response<HistoryData>>) {
|
||||
info!("Handling get public key request");
|
||||
let history_proof = directory.key_history(&label, history_params.into()).await;
|
||||
|
||||
match history_proof {
|
||||
Ok((history_proof, epoch_hash)) => (
|
||||
StatusCode::OK,
|
||||
Json(Response::success(HistoryData {
|
||||
history_proof: history_proof,
|
||||
epoch_data: epoch_hash.into(),
|
||||
})),
|
||||
),
|
||||
Err(e) => {
|
||||
error!(err = ?e, "Failed to get AKD public key");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(Response::fail(e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
52
akd/crates/reader/src/routes/lookup.rs
Normal file
52
akd/crates/reader/src/routes/lookup.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
use crate::{
|
||||
routes::{get_epoch_hash::EpochData, Response},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AkdLabelB64(bitwarden_encoding::B64);
|
||||
|
||||
impl From<AkdLabelB64> for akd::AkdLabel {
|
||||
fn from(label_b64: AkdLabelB64) -> Self {
|
||||
akd::AkdLabel(label_b64.0.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LookupRequest {
|
||||
/// the label to look up encoded as base64
|
||||
pub label_b64: AkdLabelB64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LookupData {
|
||||
pub lookup_proof: akd::LookupProof,
|
||||
pub epoch_data: EpochData,
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn lookup_handler(
|
||||
State(AppState { directory, .. }): State<AppState>,
|
||||
Json(LookupRequest { label_b64 }): Json<LookupRequest>,
|
||||
) -> (StatusCode, Json<Response<LookupData>>) {
|
||||
info!("Handling get public key request");
|
||||
let lookup_proof = directory.lookup(label_b64.into()).await;
|
||||
|
||||
match lookup_proof {
|
||||
Ok((lookup_proof, epoch_hash)) => (
|
||||
StatusCode::OK,
|
||||
Json(Response::success(LookupData {
|
||||
lookup_proof,
|
||||
epoch_data: epoch_hash.into(),
|
||||
})),
|
||||
),
|
||||
Err(e) => {
|
||||
error!(err = ?e, "Failed to get AKD public key");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(Response::fail(e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,86 @@
|
||||
use akd::errors::AkdError;
|
||||
use axum::routing::get;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod audit;
|
||||
mod batch_lookup;
|
||||
mod get_epoch_hash;
|
||||
mod get_public_key;
|
||||
mod health;
|
||||
mod key_history;
|
||||
mod lookup;
|
||||
pub mod response_types;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub fn api_routes() -> axum::Router<AppState> {
|
||||
axum::Router::new().route("/health", get(health::health_handler))
|
||||
axum::Router::new()
|
||||
.route("/health", get(health::health_handler))
|
||||
.route("/public_key", get(get_public_key::get_public_key_handler))
|
||||
.route("/epoch_hash", get(get_epoch_hash::get_epoch_hash_handler))
|
||||
.route("/lookup", get(lookup::lookup_handler))
|
||||
.route("/key_history", get(key_history::key_history_handler))
|
||||
.route("/batch_lookup", get(batch_lookup::batch_lookup_handler))
|
||||
.route("/audit", get(audit::audit_handler))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Response<T> {
|
||||
success: bool,
|
||||
data: Option<T>,
|
||||
error: Option<ResponseError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ResponseError {
|
||||
pub akd_error_type: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl From<AkdError> for ResponseError {
|
||||
fn from(err: AkdError) -> Self {
|
||||
ResponseError {
|
||||
akd_error_type: match &err {
|
||||
AkdError::TreeNode(_) => "TreeNode".to_string(),
|
||||
AkdError::Directory(err) => match err {
|
||||
akd::errors::DirectoryError::Verification(_) => "VerificationError".to_string(),
|
||||
akd::errors::DirectoryError::InvalidEpoch(_) => "InvalidEpoch".to_string(),
|
||||
akd::errors::DirectoryError::ReadOnlyDirectory(_) => {
|
||||
"ReadOnlyDirectory".to_string()
|
||||
}
|
||||
akd::errors::DirectoryError::Publish(_) => "Publish".to_string(),
|
||||
},
|
||||
AkdError::AzksErr(_) => "AzksErr".to_string(),
|
||||
AkdError::Vrf(_) => "Vrf".to_string(),
|
||||
AkdError::Storage(err) => match err {
|
||||
akd::errors::StorageError::NotFound(_) => "NotFound".to_string(),
|
||||
akd::errors::StorageError::Transaction(_) => "Transaction".to_string(),
|
||||
akd::errors::StorageError::Connection(_) => "Connection".to_string(),
|
||||
akd::errors::StorageError::Other(_) => "Other".to_string(),
|
||||
},
|
||||
AkdError::AuditErr(_) => "AuditErr".to_string(),
|
||||
AkdError::Parallelism(_) => "Parallelism".to_string(),
|
||||
AkdError::TestErr(_) => "TestErr".to_string(),
|
||||
},
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: Serialize + Deserialize<'a>> Response<T> {
|
||||
fn success(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(err: AkdError) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
akd/crates/reader/src/routes/response_types.rs
Normal file
9
akd/crates/reader/src/routes/response_types.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use crate::routes::Response;
|
||||
|
||||
pub type AuditResponse = Response<crate::routes::audit::AuditData>;
|
||||
pub type BatchLookupData = Response<crate::routes::batch_lookup::BatchLookupData>;
|
||||
pub type HealthData = Response<crate::routes::health::HealthData>;
|
||||
pub type HistoryData = Response<crate::routes::key_history::HistoryData>;
|
||||
pub type LookupData = Response<crate::routes::lookup::LookupData>;
|
||||
pub type EpochData = Response<crate::routes::get_epoch_hash::EpochData>;
|
||||
pub type PublicKeyData = Response<crate::routes::get_public_key::PublicKeyData>;
|
||||
Reference in New Issue
Block a user