diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index d89617e6b..d3be68f5c 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -14,8 +14,7 @@ use crate::{COMPONENT, LOG_TARGET, StorageKeyEpoch, StoredPrivateRecord}; mod migrations; mod queries; -#[cfg(test)] -pub(crate) use queries::ListTransactionsParams; +pub(crate) use queries::{ListTransactionsParams, ListedTransaction}; // VALIDATOR DATABASE // ================================================================================================ @@ -140,18 +139,8 @@ impl ValidatorDbReader { .await } - /// Loads all validated private transactions in insertion order. - pub(crate) async fn load_all_transactions( - &self, - ) -> Result, DatabaseError> { - self.reader.read("load_all_transactions", queries::load_all_transactions).await - } - /// Loads one page of committed transactions in chronological order i.e. `(block_num, /// block_tx_index)`. - // `expect(dead_code)` is gated to non-test builds because tests do call this, which would leave - // the expectation unfulfilled. - #[cfg_attr(not(test), expect(dead_code, reason = "used in follow-up PR"))] pub(crate) async fn list_validated_transactions( &self, params: queries::ListTransactionsParams, @@ -500,30 +489,6 @@ mod tests { assert_eq!(by_setup, vec![expected.clone()]); } - #[tokio::test] - async fn validated_private_transactions_are_loaded_in_insertion_order() { - let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); - let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap(); - let transaction_ids = [ - TransactionId::from_raw(Word::from([9u32, 0, 0, 0])), - TransactionId::from_raw(Word::from([1u32, 0, 0, 0])), - TransactionId::from_raw(Word::from([5u32, 0, 0, 0])), - ]; - let records = transaction_ids - .into_iter() - .zip([1u8, 2, 3]) - .map(|(transaction_id, seed)| private_record(transaction_id, seed)) - .collect::>(); - - for record in records.clone() { - db.insert_validated_private_transaction(record).await.unwrap(); - } - - let loaded = db.load_all_transactions().await.unwrap(); - - assert_eq!(loaded, records); - } - /// Validated transactions that are not part of a signed block have no position in the committed /// order, so the listing does not surface them at all. #[tokio::test] diff --git a/bin/validator/src/db/queries/load_all_transactions/load_all_transactions.sql b/bin/validator/src/db/queries/load_all_transactions/load_all_transactions.sql deleted file mode 100644 index ccd00cc94..000000000 --- a/bin/validator/src/db/queries/load_all_transactions/load_all_transactions.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Returns every validated private transaction in local insertion order. -SELECT - chain_id, - key_epoch, - id, - validator_id, - setup_context_id, - format_version, - cipher_nonce, - encrypted_record, - encrypted_record_key -FROM validated_transactions -ORDER BY insertion_sequence; diff --git a/bin/validator/src/db/queries/load_all_transactions/mod.rs b/bin/validator/src/db/queries/load_all_transactions/mod.rs deleted file mode 100644 index 44f09123e..000000000 --- a/bin/validator/src/db/queries/load_all_transactions/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Reads every validated private transaction in local insertion order. - -use miden_node_db::DatabaseError; -use miden_node_db::sqlite::ReadTx; - -use crate::StoredPrivateRecord; -use crate::db::queries::private_record_row::private_record_from_row; - -const SQL: &str = include_str!("load_all_transactions.sql"); - -/// Loads all validated private transactions in insertion order. -pub fn load_all_transactions(tx: &ReadTx<'_>) -> Result, DatabaseError> { - tx.query(SQL, &[], private_record_from_row) -} diff --git a/bin/validator/src/db/queries/mod.rs b/bin/validator/src/db/queries/mod.rs index fc4133b62..39055764e 100644 --- a/bin/validator/src/db/queries/mod.rs +++ b/bin/validator/src/db/queries/mod.rs @@ -36,9 +36,6 @@ pub use list_validated_transactions::{ list_validated_transactions, }; -mod load_all_transactions; -pub use load_all_transactions::load_all_transactions; - mod load_block_header; pub use load_block_header::load_block_header; diff --git a/bin/validator/src/server/admin_service.rs b/bin/validator/src/server/admin_service.rs deleted file mode 100644 index d1c173e05..000000000 --- a/bin/validator/src/server/admin_service.rs +++ /dev/null @@ -1,429 +0,0 @@ -use std::sync::Arc; - -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use miden_protocol::utils::serde::Serializable; -use rand_core_06::OsRng; -use serde::{Deserialize, Serialize}; - -use crate::db::ValidatorDbReader; -use crate::{GoldenOperatorKey, PrivateRecordError, StoredPrivateRecord}; - -const LIST_TRANSACTIONS_PATH: &str = "/admin/transactions"; -const ISSUE_SHARE_PATH: &str = "/admin/decryption-share"; - -#[derive(Clone)] -struct ValidatorAdminService { - operator_key: Arc, - /// Read-only handle: the administration API lists stored records and issues decryption shares, - /// and must never mutate validator state. - reader: ValidatorDbReader, -} - -impl ValidatorAdminService { - fn new(operator_key: GoldenOperatorKey, reader: ValidatorDbReader) -> Self { - Self { - operator_key: Arc::new(operator_key), - reader, - } - } -} - -pub(super) fn router(operator_key: GoldenOperatorKey, reader: ValidatorDbReader) -> Router { - Router::new() - .route(LIST_TRANSACTIONS_PATH, get(list_validated_private_transactions)) - .route(ISSUE_SHARE_PATH, post(issue_decryption_share)) - .with_state(ValidatorAdminService::new(operator_key, reader)) -} - -#[derive(Debug, Deserialize, Serialize)] -struct ValidatedPrivateTransaction { - transaction_id: String, - final_ciphertext: String, - cipher_nonce: String, - encrypted_record_key: String, - decryption_context: String, -} - -impl From for ValidatedPrivateTransaction { - fn from(record: StoredPrivateRecord) -> Self { - Self { - transaction_id: hex::encode(record.context().transaction_id().to_bytes()), - final_ciphertext: hex::encode(record.encrypted_record()), - cipher_nonce: hex::encode(record.nonce()), - encrypted_record_key: hex::encode(record.encrypted_record_key()), - decryption_context: hex::encode(record.context().to_bytes()), - } - } -} - -#[derive(Debug, Deserialize, Serialize)] -struct ListValidatedPrivateTransactionsResponse { - transactions: Vec, -} - -async fn list_validated_private_transactions( - State(service): State, -) -> Result, ApiError> { - let records = - service.reader.load_all_transactions().await.map_err(|_error| { - ApiError::internal("failed to list validated private transactions") - })?; - - Ok(Json(ListValidatedPrivateTransactionsResponse { - transactions: records.into_iter().map(Into::into).collect(), - })) -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct IssueDecryptionShareRequest { - ciphertext: String, - decryption_context: String, -} - -#[derive(Debug, Deserialize, Serialize)] -struct IssueDecryptionShareResponse { - decryption_share: String, -} - -async fn issue_decryption_share( - State(service): State, - Json(request): Json, -) -> Result, ApiError> { - let ciphertext = decode_hex("ciphertext", &request.ciphertext)?; - let decryption_context = decode_hex("decryption_context", &request.decryption_context)?; - let decryption_share = service - .operator_key - .issue_decryption_share(&mut OsRng, &ciphertext, &decryption_context) - .map_err(|error| map_share_error(&error))?; - - Ok(Json(IssueDecryptionShareResponse { - decryption_share: hex::encode(decryption_share), - })) -} - -fn decode_hex(field: &str, value: &str) -> Result, ApiError> { - hex::decode(value).map_err(|_error| ApiError::bad_request(format!("{field} must be valid hex"))) -} - -fn map_share_error(error: &PrivateRecordError) -> ApiError { - match error { - PrivateRecordError::InvalidGoldenEncoding(_) - | PrivateRecordError::InvalidEncryptedRecordKey - | PrivateRecordError::DecryptionContextMismatch => ApiError::bad_request(error.to_string()), - _ => ApiError::internal("failed to issue Golden decryption share"), - } -} - -#[derive(Debug)] -struct ApiError { - status: StatusCode, - message: String, -} - -impl ApiError { - fn bad_request(message: impl Into) -> Self { - Self { - status: StatusCode::BAD_REQUEST, - message: message.into(), - } - } - - fn internal(message: impl Into) -> Self { - Self { - status: StatusCode::INTERNAL_SERVER_ERROR, - message: message.into(), - } - } -} - -#[derive(Serialize)] -struct ErrorResponse { - error: String, -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - (self.status, Json(ErrorResponse { error: self.message })).into_response() - } -} - -#[cfg(test)] -mod tests { - use axum::body::{Body, to_bytes}; - use axum::http::Request; - use chacha20poly1305::aead::{Aead, KeyInit, Payload}; - use chacha20poly1305::{XChaCha20Poly1305, XNonce}; - use golden_ehtdh1::wire::{from_wire_bytes, to_wire_bytes}; - use golden_ehtdh1::{Ciphertext, Combiner, DecryptionShare}; - use golden_halo2curves::golden_group::Secp256k1GoldenGroup; - use miden_protocol::Word; - use miden_protocol::account::auth::AuthScheme; - use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; - use miden_protocol::transaction::{TransactionId, TransactionInputs}; - use miden_protocol::utils::serde::{Deserializable, Serializable}; - use miden_testing::{Auth, MockChainBuilder}; - use rand_chacha_03::ChaCha20Rng; - use rand_chacha_03::rand_core::SeedableRng; - use tower::ServiceExt; - - use super::*; - use crate::db::ValidatorDbWriter; - use crate::storage_key::tests::operator_keys; - use crate::{ - PrivateRecordChainId, - PrivateRecordCombiner, - PrivateRecordContext, - PrivateRecordId, - PrivateRecordSealer, - PrivateRecordShareRequest, - StoredPrivateRecord, - }; - - fn target_record( - operator_key: &GoldenOperatorKey, - transaction_id: TransactionId, - seed: u8, - plaintext: &[u8], - ) -> StoredPrivateRecord { - let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); - let record_id = PrivateRecordId::new(transaction_id, &signer.public_key()); - let context = PrivateRecordContext::new( - PrivateRecordChainId::new([7; 32]), - operator_key.key_epoch(), - transaction_id, - ); - PrivateRecordSealer::from_operator_key(operator_key) - .seal(&mut ChaCha20Rng::from_seed([seed; 32]), record_id, context, plaintext) - .unwrap() - } - - fn transaction_inputs() -> TransactionInputs { - let mut builder = MockChainBuilder::new(); - let account = builder - .add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - }) - .unwrap(); - builder.build().unwrap().get_transaction_inputs(&account, &[], &[]).unwrap() - } - - async fn test_database() -> (tempfile::TempDir, ValidatorDbWriter, ValidatorDbReader) { - let directory = tempfile::tempdir().unwrap(); - let writer = crate::db::setup(directory.path().join("validator.sqlite3")).await.unwrap(); - let reader = writer.reader(); - (directory, writer, reader) - } - - fn share_request(record: &StoredPrivateRecord) -> IssueDecryptionShareRequest { - IssueDecryptionShareRequest { - ciphertext: hex::encode(record.encrypted_record_key()), - decryption_context: hex::encode(record.context().to_bytes()), - } - } - - async fn issue( - service: &ValidatorAdminService, - request: IssueDecryptionShareRequest, - ) -> Result { - issue_decryption_share(State(service.clone()), Json(request)) - .await - .map(|Json(response)| response) - } - - #[tokio::test] - async fn listed_record_drives_threshold_recovery() { - let mut keys = operator_keys(); - let record_owner = keys.pop().unwrap(); - let second = keys.pop().unwrap(); - let first = keys.pop().unwrap(); - let public_key_set = record_owner.public_key_set().clone(); - let setup_context = record_owner.setup_context().clone(); - let (_directory, writer, reader) = test_database().await; - let first_service = ValidatorAdminService::new(first, reader.clone()); - let second_service = ValidatorAdminService::new(second, reader); - let inputs = transaction_inputs(); - let transaction_id = TransactionId::from_raw(Word::from([8u32, 7, 6, 5])); - let record = target_record(&record_owner, transaction_id, 10, &inputs.to_bytes()); - writer.insert_validated_private_transaction(record.clone()).await.unwrap(); - - let Json(response) = - list_validated_private_transactions(State(first_service.clone())).await.unwrap(); - let [listed] = response.transactions.as_slice() else { - panic!("expected one listed transaction"); - }; - assert_eq!(listed.transaction_id, hex::encode(transaction_id.to_bytes())); - assert_eq!(listed.final_ciphertext, hex::encode(record.encrypted_record())); - assert_eq!(listed.cipher_nonce, hex::encode(record.nonce())); - assert_eq!(listed.encrypted_record_key, hex::encode(record.encrypted_record_key())); - assert_eq!(listed.decryption_context, hex::encode(record.context().to_bytes())); - - let request = share_request(&record); - let share_bytes = [ - issue(&first_service, request.clone()).await.unwrap().decryption_share, - issue(&second_service, request).await.unwrap().decryption_share, - ]; - let ciphertext: Ciphertext = - from_wire_bytes(record.encrypted_record_key()).unwrap(); - let shares = share_bytes - .iter() - .map(|share| { - let bytes = hex::decode(share).unwrap(); - from_wire_bytes::>(&bytes).unwrap() - }) - .collect::>(); - let context = record.context().to_bytes(); - let content_key = Combiner::new(public_key_set, setup_context) - .unwrap() - .combine_exact_with_associated_data(&ciphertext, &context, &context, &shares) - .unwrap(); - let plaintext = XChaCha20Poly1305::new_from_slice(&content_key) - .unwrap() - .decrypt( - &XNonce::from(*record.nonce()), - Payload { - msg: record.encrypted_record(), - aad: &context, - }, - ) - .unwrap(); - - assert_eq!(TransactionInputs::read_from_bytes(&plaintext).unwrap(), inputs); - } - - #[tokio::test] - async fn list_uses_insertion_order() { - let mut keys = operator_keys(); - let (_directory, writer, reader) = test_database().await; - let transaction_ids = [ - TransactionId::from_raw(Word::from([9u32, 0, 0, 0])), - TransactionId::from_raw(Word::from([1u32, 0, 0, 0])), - TransactionId::from_raw(Word::from([5u32, 0, 0, 0])), - ]; - for (seed, transaction_id) in [11u8, 12, 13].into_iter().zip(transaction_ids) { - let record = target_record(&keys[0], transaction_id, seed, b"record"); - writer.insert_validated_private_transaction(record).await.unwrap(); - } - - let service = ValidatorAdminService::new(keys.remove(0), reader); - let Json(response) = list_validated_private_transactions(State(service)).await.unwrap(); - assert_eq!( - response - .transactions - .iter() - .map(|transaction| transaction.transaction_id.as_str()) - .collect::>(), - transaction_ids - .iter() - .map(|transaction_id| hex::encode(transaction_id.to_bytes())) - .collect::>(), - ); - } - - #[tokio::test] - async fn shares_for_different_ciphertexts_are_not_reusable() { - let mut keys = operator_keys(); - let record_owner = keys.pop().unwrap(); - let second = ValidatorAdminService::new(keys.pop().unwrap(), test_database().await.2); - let first = ValidatorAdminService::new(keys.pop().unwrap(), test_database().await.2); - let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4])); - let first_record = target_record(&record_owner, transaction_id, 2, b"same plaintext"); - let second_record = target_record(&record_owner, transaction_id, 3, b"same plaintext"); - assert_eq!(first_record.context(), second_record.context()); - assert_ne!(first_record.encrypted_record_key(), second_record.encrypted_record_key()); - - let shares = [ - issue(&first, share_request(&first_record)).await.unwrap().decryption_share, - issue(&second, share_request(&second_record)).await.unwrap().decryption_share, - ] - .map(|share| hex::decode(share).unwrap()); - let request = PrivateRecordShareRequest::for_record(&first_record); - let result = PrivateRecordCombiner::from_operator_key(&record_owner).unwrap().open( - &request, - &first_record, - &shares, - ); - - assert!(matches!(result, Err(PrivateRecordError::ShareCombination(_)))); - } - - #[tokio::test] - async fn invalid_share_requests_return_bad_request() { - let mut keys = operator_keys(); - let record = target_record( - &keys[0], - TransactionId::from_raw(Word::from([1u32, 2, 3, 4])), - 4, - b"record", - ); - let context = record.context().to_bytes(); - let (_directory, _writer, reader) = test_database().await; - - let invalid_hex = IssueDecryptionShareRequest { - ciphertext: "not hex".to_owned(), - decryption_context: hex::encode(&context), - }; - let error = issue(&ValidatorAdminService::new(keys.remove(0), reader.clone()), invalid_hex) - .await - .unwrap_err(); - assert_eq!(error.status, StatusCode::BAD_REQUEST); - - let mut short_rng = ChaCha20Rng::from_seed([5; 32]); - let short_ciphertext = keys[0] - .sealing_key() - .seal_bytes_with_associated_data(&mut short_rng, &[0; 31], &context) - .unwrap(); - let wrong_size = IssueDecryptionShareRequest { - ciphertext: hex::encode(to_wire_bytes(&short_ciphertext)), - decryption_context: hex::encode(context), - }; - let error = issue(&ValidatorAdminService::new(keys.remove(0), reader.clone()), wrong_size) - .await - .unwrap_err(); - assert_eq!(error.status, StatusCode::BAD_REQUEST); - - let wrong_context = IssueDecryptionShareRequest { - ciphertext: hex::encode(record.encrypted_record_key()), - decryption_context: hex::encode(b"wrong context"), - }; - let error = - issue(&ValidatorAdminService::new(operator_keys().remove(0), reader), wrong_context) - .await - .unwrap_err(); - assert_eq!(error.status, StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn router_exposes_only_the_json_admin_routes() { - let (_directory, _writer, reader) = test_database().await; - let app = router(operator_keys().remove(0), reader); - - let response = app - .clone() - .oneshot(Request::get(LIST_TRANSACTIONS_PATH).body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers().get("content-type").unwrap(), "application/json",); - let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - assert_eq!(body.as_ref(), br#"{"transactions":[]}"#); - - let response = app - .clone() - .oneshot( - Request::post(ISSUE_SHARE_PATH) - .header("content-type", "application/json") - .body(Body::from(r#"{"ciphertext":"not hex","decryption_context":""}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - let response = app.oneshot(Request::get("/").body(Body::empty()).unwrap()).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/bin/validator/src/server/admin_service/error.rs b/bin/validator/src/server/admin_service/error.rs new file mode 100644 index 000000000..86c64b8ec --- /dev/null +++ b/bin/validator/src/server/admin_service/error.rs @@ -0,0 +1,46 @@ +//! Error responses shared by every administration endpoint. + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +#[derive(Debug)] +pub(super) struct ApiError { + pub(super) status: StatusCode, + pub(super) message: String, +} + +impl ApiError { + pub(super) fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + + pub(super) fn not_found(message: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: message.into(), + } + } + + pub(super) fn internal(message: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } + } +} + +#[derive(Serialize)] +struct ErrorResponse { + error: String, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(ErrorResponse { error: self.message })).into_response() + } +} diff --git a/bin/validator/src/server/admin_service/get_transaction.rs b/bin/validator/src/server/admin_service/get_transaction.rs new file mode 100644 index 000000000..29d32febe --- /dev/null +++ b/bin/validator/src/server/admin_service/get_transaction.rs @@ -0,0 +1,54 @@ +//! Retrieval of one validated transaction's full sealed record by id. + +use axum::Json; +use axum::extract::{Path, State}; +use miden_protocol::transaction::TransactionId; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use serde::{Deserialize, Serialize}; + +use crate::StoredPrivateRecord; +use crate::server::admin_service::error::ApiError; +use crate::server::admin_service::{ValidatorAdminService, decode_hex}; + +/// The full record of one validated transaction, returned by the single-transaction endpoint. +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct ValidatedPrivateTransaction { + pub(super) transaction_id: String, + pub(super) final_ciphertext: String, + pub(super) cipher_nonce: String, + pub(super) encrypted_record_key: String, + pub(super) decryption_context: String, +} + +impl From for ValidatedPrivateTransaction { + fn from(record: StoredPrivateRecord) -> Self { + Self { + transaction_id: hex::encode(record.context().transaction_id().to_bytes()), + final_ciphertext: hex::encode(record.encrypted_record()), + cipher_nonce: hex::encode(record.nonce()), + encrypted_record_key: hex::encode(record.encrypted_record_key()), + decryption_context: hex::encode(record.context().to_bytes()), + } + } +} + +pub(super) async fn get_validated_private_transaction( + State(service): State, + Path(transaction_id): Path, +) -> Result, ApiError> { + let transaction_id = parse_transaction_id(&transaction_id)?; + let record = service + .reader + .load_private_record(transaction_id) + .await + .map_err(|_error| ApiError::internal("failed to load the private record"))? + .ok_or_else(|| ApiError::not_found("transaction not found"))?; + Ok(Json(record.into())) +} + +fn parse_transaction_id(value: &str) -> Result { + let bytes = decode_hex("transaction_id", value)?; + TransactionId::read_from_bytes(&bytes).map_err(|_error| { + ApiError::bad_request("transaction_id must be a canonical transaction id") + }) +} diff --git a/bin/validator/src/server/admin_service/issue_decryption_share.rs b/bin/validator/src/server/admin_service/issue_decryption_share.rs new file mode 100644 index 000000000..93f7b203a --- /dev/null +++ b/bin/validator/src/server/admin_service/issue_decryption_share.rs @@ -0,0 +1,46 @@ +//! Issuance of Golden decryption shares for threshold recovery. + +use axum::Json; +use axum::extract::State; +use rand_core_06::OsRng; +use serde::{Deserialize, Serialize}; + +use crate::PrivateRecordError; +use crate::server::admin_service::error::ApiError; +use crate::server::admin_service::{ValidatorAdminService, decode_hex}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct IssueDecryptionShareRequest { + pub(super) ciphertext: String, + pub(super) decryption_context: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct IssueDecryptionShareResponse { + pub(super) decryption_share: String, +} + +pub(super) async fn issue_decryption_share( + State(service): State, + Json(request): Json, +) -> Result, ApiError> { + let ciphertext = decode_hex("ciphertext", &request.ciphertext)?; + let decryption_context = decode_hex("decryption_context", &request.decryption_context)?; + let decryption_share = service + .operator_key + .issue_decryption_share(&mut OsRng, &ciphertext, &decryption_context) + .map_err(|error| map_share_error(&error))?; + + Ok(Json(IssueDecryptionShareResponse { + decryption_share: hex::encode(decryption_share), + })) +} + +fn map_share_error(error: &PrivateRecordError) -> ApiError { + match error { + PrivateRecordError::InvalidGoldenEncoding(_) + | PrivateRecordError::InvalidEncryptedRecordKey + | PrivateRecordError::DecryptionContextMismatch => ApiError::bad_request(error.to_string()), + _ => ApiError::internal("failed to issue Golden decryption share"), + } +} diff --git a/bin/validator/src/server/admin_service/list_transactions.rs b/bin/validator/src/server/admin_service/list_transactions.rs new file mode 100644 index 000000000..ad2652c2c --- /dev/null +++ b/bin/validator/src/server/admin_service/list_transactions.rs @@ -0,0 +1,181 @@ +//! Paginated listing of committed validated transactions. + +use axum::Json; +use axum::extract::{Query, State}; +use miden_protocol::block::BlockNumber; +use miden_protocol::utils::serde::Serializable; +use serde::{Deserialize, Serialize}; + +use crate::StoredPrivateRecord; +use crate::db::{ListTransactionsParams, ListedTransaction}; +use crate::server::admin_service::ValidatorAdminService; +use crate::server::admin_service::error::ApiError; + +/// Page size used when a listing request does not specify one. +pub(super) const DEFAULT_PAGE_LIMIT: usize = 100; +/// Maximum page size for metadata-only listing pages. +pub(super) const MAX_PAGE_LIMIT: usize = 1000; +/// Maximum page size when full sealed records are included; records carry the encrypted transaction +/// inputs, so record pages are kept small. +pub(super) const MAX_RECORD_PAGE_LIMIT: usize = 100; + +/// Query parameters of the listing endpoint. +/// +/// `block_from`/`block_to` restrict results to the inclusive block range, and +/// `(block_from, tx_index_from)` is the pagination cursor: a response reports the position of the +/// last row it included, and the next page is the same request resumed one position past it. Only +/// committed transactions are listed; ones that are still in flight, that were never included in a +/// signed block, or that predate block linkage have no place in the committed order and are +/// reachable by transaction id instead. +#[derive(Debug, Default, Deserialize)] +pub(super) struct ListTransactionsQuery { + pub(super) limit: Option, + #[serde(default)] + pub(super) include_records: bool, + pub(super) block_from: Option, + /// Index within `block_from` to resume at; requires `block_from`. + pub(super) tx_index_from: Option, + pub(super) block_to: Option, +} + +/// Metadata identifying one validated transaction. The full sealed record is attached only when the +/// request opts in with `include_records=true`. +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct ListedValidatedTransaction { + pub(super) transaction_id: String, + /// Block that includes this transaction. + pub(super) block_num: u32, + /// Index of this transaction within its block. Together with `block_num` this is the + /// transaction's position in the committed order. + pub(super) block_tx_index: u32, + pub(super) key_epoch: String, + pub(super) setup_context_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) record: Option, +} + +impl From for ListedValidatedTransaction { + fn from(item: ListedTransaction) -> Self { + Self { + transaction_id: hex::encode(item.transaction_id.to_bytes()), + block_num: item.block_num.as_u32(), + block_tx_index: item.block_tx_index, + key_epoch: hex::encode(item.key_epoch.as_bytes()), + setup_context_id: hex::encode(item.setup_context_id), + record: None, + } + } +} + +/// The sealed private record of one validated transaction. +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct PrivateRecordPayload { + pub(super) final_ciphertext: String, + pub(super) cipher_nonce: String, + pub(super) encrypted_record_key: String, + pub(super) decryption_context: String, +} + +impl From for PrivateRecordPayload { + fn from(record: StoredPrivateRecord) -> Self { + Self { + final_ciphertext: hex::encode(record.encrypted_record()), + cipher_nonce: hex::encode(record.nonce()), + encrypted_record_key: hex::encode(record.encrypted_record_key()), + decryption_context: hex::encode(record.context().to_bytes()), + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct ListValidatedPrivateTransactionsResponse { + pub(super) transactions: Vec, + pub(super) pagination: PaginationInfo, +} + +/// How far the sweep got, mirroring the `PaginationInfo` message the node's sync RPCs return. +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct PaginationInfo { + /// Highest block this validator has signed, so a caller can tell whether it has caught up. + pub(super) chain_tip: u32, + /// Block of the last transaction in this response. To request the next page, repeat the request + /// with `block_from` set to this and `tx_index_from` set to `block_tx_index + 1`. `null` when + /// the page is empty, which is how a sweep ends. + pub(super) block_num: Option, + /// Index within `block_num` of the last transaction in this response. `null` when the page is + /// empty. + pub(super) block_tx_index: Option, +} + +pub(super) async fn list_validated_private_transactions( + State(service): State, + Query(query): Query, +) -> Result, ApiError> { + let max_limit = if query.include_records { + MAX_RECORD_PAGE_LIMIT + } else { + MAX_PAGE_LIMIT + }; + let limit = query.limit.unwrap_or(DEFAULT_PAGE_LIMIT); + if limit == 0 || limit > max_limit { + return Err(ApiError::bad_request(format!("limit must be between 1 and {max_limit}"))); + } + if query.tx_index_from.is_some() && query.block_from.is_none() { + return Err(ApiError::bad_request("tx_index_from requires block_from")); + } + if let (Some(from), Some(to)) = (query.block_from, query.block_to) + && from > to + { + return Err(ApiError::bad_request("block_from must not exceed block_to")); + } + + let start = query + .block_from + .map(|from| (BlockNumber::from(from), query.tx_index_from.unwrap_or(0))); + let transactions = service + .reader + .list_validated_transactions(ListTransactionsParams { + start, + block_to: query.block_to.map(BlockNumber::from), + limit, + }) + .await + .map_err(|_error| ApiError::internal("failed to list validated private transactions"))?; + + // Read the tip after the page, so it can never come back older than a block the page lists. A + // validator that has signed nothing reports 0, matching how `load_initial_metrics` treats it. + let chain_tip = service + .reader + .load_chain_tip() + .await + .map_err(|_error| ApiError::internal("failed to load the chain tip"))? + .map_or(0, |header| header.block_num().as_u32()); + // The position of the last row is exactly what the next page resumes one past. + let block_num = transactions.last().map(|item| item.block_num.as_u32()); + let block_tx_index = transactions.last().map(|item| item.block_tx_index); + + let mut listed = Vec::with_capacity(transactions.len()); + for item in transactions { + let transaction_id = item.transaction_id; + let mut listed_item = ListedValidatedTransaction::from(item); + if query.include_records { + // Every listed transaction references a validated record via a foreign key and records + // are never deleted, so a missing record is an internal inconsistency. + let record = service + .reader + .load_private_record(transaction_id) + .await + .map_err(|_error| ApiError::internal("failed to load a private record"))? + .ok_or_else(|| { + ApiError::internal("a listed transaction's private record is missing") + })?; + listed_item.record = Some(record.into()); + } + listed.push(listed_item); + } + + Ok(Json(ListValidatedPrivateTransactionsResponse { + transactions: listed, + pagination: PaginationInfo { chain_tip, block_num, block_tx_index }, + })) +} diff --git a/bin/validator/src/server/admin_service/mod.rs b/bin/validator/src/server/admin_service/mod.rs new file mode 100644 index 000000000..7ae5ce27c --- /dev/null +++ b/bin/validator/src/server/admin_service/mod.rs @@ -0,0 +1,58 @@ +//! Private validator administration API. +//! +//! One submodule per endpoint, holding its handler and its request/response types; shared pieces +//! (the service state, the routing table, hex parsing, and the error type) live here and in +//! [`error`]. + +use std::sync::Arc; + +use axum::Router; +use axum::routing::{get, post}; + +use crate::GoldenOperatorKey; +use crate::db::ValidatorDbReader; +use crate::server::admin_service::error::ApiError; + +#[cfg(test)] +mod tests; + +mod error; +mod get_transaction; +mod issue_decryption_share; +mod list_transactions; + +const LIST_TRANSACTIONS_PATH: &str = "/admin/v1/transactions"; +const GET_TRANSACTION_PATH: &str = "/admin/v1/transactions/{transaction_id}"; +const ISSUE_SHARE_PATH: &str = "/admin/v1/decryption-share"; + +#[derive(Clone)] +struct ValidatorAdminService { + operator_key: Arc, + /// Read-only handle: the administration API lists stored records and issues decryption shares, + /// and must never mutate validator state. + reader: ValidatorDbReader, +} + +impl ValidatorAdminService { + fn new(operator_key: GoldenOperatorKey, reader: ValidatorDbReader) -> Self { + Self { + operator_key: Arc::new(operator_key), + reader, + } + } +} + +pub(super) fn router(operator_key: GoldenOperatorKey, reader: ValidatorDbReader) -> Router { + Router::new() + .route( + LIST_TRANSACTIONS_PATH, + get(list_transactions::list_validated_private_transactions), + ) + .route(GET_TRANSACTION_PATH, get(get_transaction::get_validated_private_transaction)) + .route(ISSUE_SHARE_PATH, post(issue_decryption_share::issue_decryption_share)) + .with_state(ValidatorAdminService::new(operator_key, reader)) +} + +fn decode_hex(field: &str, value: &str) -> Result, ApiError> { + hex::decode(value).map_err(|_error| ApiError::bad_request(format!("{field} must be valid hex"))) +} diff --git a/bin/validator/src/server/admin_service/tests.rs b/bin/validator/src/server/admin_service/tests.rs new file mode 100644 index 000000000..db225bbed --- /dev/null +++ b/bin/validator/src/server/admin_service/tests.rs @@ -0,0 +1,508 @@ +//! Tests exercising the administration endpoints end to end. + +use axum::Json; +use axum::body::{Body, to_bytes}; +use axum::extract::{Path, Query, State}; +use axum::http::{Request, StatusCode}; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use golden_ehtdh1::wire::{from_wire_bytes, to_wire_bytes}; +use golden_ehtdh1::{Ciphertext, Combiner, DecryptionShare}; +use golden_halo2curves::golden_group::Secp256k1GoldenGroup; +use miden_protocol::Word; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::block::BlockHeader; +use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey; +use miden_protocol::transaction::{TransactionId, TransactionInputs}; +use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_testing::{Auth, MockChainBuilder}; +use rand_chacha_03::ChaCha20Rng; +use rand_chacha_03::rand_core::SeedableRng; +use tower::ServiceExt; + +use super::error::ApiError; +use super::get_transaction::get_validated_private_transaction; +use super::issue_decryption_share::{ + IssueDecryptionShareRequest, + IssueDecryptionShareResponse, + issue_decryption_share, +}; +use super::list_transactions::{ + ListTransactionsQuery, + ListValidatedPrivateTransactionsResponse, + MAX_PAGE_LIMIT, + MAX_RECORD_PAGE_LIMIT, + list_validated_private_transactions, +}; +use super::{ISSUE_SHARE_PATH, LIST_TRANSACTIONS_PATH, ValidatorAdminService, router}; +use crate::db::{ValidatorDbReader, ValidatorDbWriter}; +use crate::storage_key::tests::operator_keys; +use crate::{ + GoldenOperatorKey, + PrivateRecordChainId, + PrivateRecordCombiner, + PrivateRecordContext, + PrivateRecordError, + PrivateRecordId, + PrivateRecordSealer, + PrivateRecordShareRequest, + StoredPrivateRecord, +}; + +fn target_record( + operator_key: &GoldenOperatorKey, + transaction_id: TransactionId, + seed: u8, + plaintext: &[u8], +) -> StoredPrivateRecord { + let signer = SigningKey::read_from_bytes(&[9; 32]).unwrap(); + let record_id = PrivateRecordId::new(transaction_id, &signer.public_key()); + let context = PrivateRecordContext::new( + PrivateRecordChainId::new([7; 32]), + operator_key.key_epoch(), + transaction_id, + ); + PrivateRecordSealer::from_operator_key(operator_key) + .seal(&mut ChaCha20Rng::from_seed([seed; 32]), record_id, context, plaintext) + .unwrap() +} + +fn transaction_inputs() -> TransactionInputs { + let mut builder = MockChainBuilder::new(); + let account = builder + .add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }) + .unwrap(); + builder.build().unwrap().get_transaction_inputs(&account, &[], &[]).unwrap() +} + +async fn test_database() -> (tempfile::TempDir, ValidatorDbWriter, ValidatorDbReader) { + let directory = tempfile::tempdir().unwrap(); + let writer = crate::db::setup(directory.path().join("validator.sqlite3")).await.unwrap(); + let reader = writer.reader(); + (directory, writer, reader) +} + +fn share_request(record: &StoredPrivateRecord) -> IssueDecryptionShareRequest { + IssueDecryptionShareRequest { + ciphertext: hex::encode(record.encrypted_record_key()), + decryption_context: hex::encode(record.context().to_bytes()), + } +} + +async fn issue( + service: &ValidatorAdminService, + request: IssueDecryptionShareRequest, +) -> Result { + issue_decryption_share(State(service.clone()), Json(request)) + .await + .map(|Json(response)| response) +} + +async fn list( + service: &ValidatorAdminService, + query: ListTransactionsQuery, +) -> Result { + list_validated_private_transactions(State(service.clone()), Query(query)) + .await + .map(|Json(response)| response) +} + +/// Commits `transactions`, in that order, in a signed block at `block_num`. Only committed +/// transactions are listed, so listing tests have to place their records in a block. +async fn commit(writer: &ValidatorDbWriter, block_num: u32, transactions: &[TransactionId]) { + writer + .insert_signed_block(BlockHeader::mock(block_num, None, None, &[]), transactions.to_vec()) + .await + .unwrap(); +} + +#[tokio::test] +async fn listed_record_drives_threshold_recovery() { + let mut keys = operator_keys(); + let record_owner = keys.pop().unwrap(); + let second = keys.pop().unwrap(); + let first = keys.pop().unwrap(); + let public_key_set = record_owner.public_key_set().clone(); + let setup_context = record_owner.setup_context().clone(); + let (_directory, writer, reader) = test_database().await; + let first_service = ValidatorAdminService::new(first, reader.clone()); + let second_service = ValidatorAdminService::new(second, reader); + let inputs = transaction_inputs(); + let transaction_id = TransactionId::from_raw(Word::from([8u32, 7, 6, 5])); + let record = target_record(&record_owner, transaction_id, 10, &inputs.to_bytes()); + writer.insert_validated_private_transaction(record.clone()).await.unwrap(); + commit(&writer, 4, &[transaction_id]).await; + + let response = list( + &first_service, + ListTransactionsQuery { + include_records: true, + ..ListTransactionsQuery::default() + }, + ) + .await + .unwrap(); + let [listed] = response.transactions.as_slice() else { + panic!("expected one listed transaction"); + }; + assert_eq!(listed.transaction_id, hex::encode(transaction_id.to_bytes())); + assert_eq!((listed.block_num, listed.block_tx_index), (4, 0)); + assert_eq!(listed.key_epoch, hex::encode(record.context().key_epoch().as_bytes())); + assert_eq!(listed.setup_context_id, hex::encode(record.setup_context_id())); + let payload = listed.record.as_ref().expect("include_records must attach the record"); + assert_eq!(payload.final_ciphertext, hex::encode(record.encrypted_record())); + assert_eq!(payload.cipher_nonce, hex::encode(record.nonce())); + assert_eq!(payload.encrypted_record_key, hex::encode(record.encrypted_record_key())); + assert_eq!(payload.decryption_context, hex::encode(record.context().to_bytes())); + + let request = share_request(&record); + let share_bytes = [ + issue(&first_service, request.clone()).await.unwrap().decryption_share, + issue(&second_service, request).await.unwrap().decryption_share, + ]; + let ciphertext: Ciphertext = + from_wire_bytes(record.encrypted_record_key()).unwrap(); + let shares = share_bytes + .iter() + .map(|share| { + let bytes = hex::decode(share).unwrap(); + from_wire_bytes::>(&bytes).unwrap() + }) + .collect::>(); + let context = record.context().to_bytes(); + let content_key = Combiner::new(public_key_set, setup_context) + .unwrap() + .combine_exact_with_associated_data(&ciphertext, &context, &context, &shares) + .unwrap(); + let plaintext = XChaCha20Poly1305::new_from_slice(&content_key) + .unwrap() + .decrypt( + &XNonce::from(*record.nonce()), + Payload { + msg: record.encrypted_record(), + aad: &context, + }, + ) + .unwrap(); + + assert_eq!(TransactionInputs::read_from_bytes(&plaintext).unwrap(), inputs); +} + +/// Metadata-only listing (the default) omits the sealed record payload entirely. +#[tokio::test] +async fn list_returns_metadata_only_by_default() { + let mut keys = operator_keys(); + let (_directory, writer, reader) = test_database().await; + let transaction_id = TransactionId::from_raw(Word::from([3u32, 0, 0, 0])); + let record = target_record(&keys[0], transaction_id, 21, b"record"); + writer.insert_validated_private_transaction(record).await.unwrap(); + commit(&writer, 1, &[transaction_id]).await; + + let service = ValidatorAdminService::new(keys.remove(0), reader); + let response = list(&service, ListTransactionsQuery::default()).await.unwrap(); + let [listed] = response.transactions.as_slice() else { + panic!("expected one listed transaction"); + }; + assert_eq!(listed.transaction_id, hex::encode(transaction_id.to_bytes())); + assert!(listed.record.is_none()); +} + +/// A validated transaction that is not in a signed block is not listed, but is still retrievable by +/// id. +#[tokio::test] +async fn list_omits_uncommitted_transactions() { + let mut keys = operator_keys(); + let (_directory, writer, reader) = test_database().await; + let transaction_id = TransactionId::from_raw(Word::from([4u32, 0, 0, 0])); + let record = target_record(&keys[0], transaction_id, 23, b"record"); + writer.insert_validated_private_transaction(record).await.unwrap(); + let service = ValidatorAdminService::new(keys.remove(0), reader); + + let response = list(&service, ListTransactionsQuery::default()).await.unwrap(); + assert!(response.transactions.is_empty()); + assert_eq!(response.pagination.block_num, None); + + let Json(fetched) = get_validated_private_transaction( + State(service), + Path(hex::encode(transaction_id.to_bytes())), + ) + .await + .unwrap(); + assert_eq!(fetched.transaction_id, hex::encode(transaction_id.to_bytes())); +} + +/// A paged sweep returns committed rows in committed order, honoring the row limit exactly, and +/// terminates with an empty page reporting no position. A block range restricts results to the rows +/// committed in range. +#[tokio::test] +async fn list_pages_in_committed_order_and_filters_by_block_range() { + let mut keys = operator_keys(); + let (_directory, writer, reader) = test_database().await; + let transaction_ids = (1u64..=5) + .map(|i| TransactionId::from_raw(Word::try_from([i, i, i, i]).unwrap())) + .collect::>(); + for (seed, transaction_id) in (31u8..=35).zip(&transaction_ids) { + let record = target_record(&keys[0], *transaction_id, seed, b"record"); + writer.insert_validated_private_transaction(record).await.unwrap(); + } + // Blocks 1 and 2 include two transactions each; the fifth is never committed. Block 1 includes + // a later insertion first, to prove committed order wins over insertion order. + commit(&writer, 1, &[transaction_ids[3], transaction_ids[0]]).await; + commit(&writer, 2, &[transaction_ids[1], transaction_ids[2]]).await; + let service = ValidatorAdminService::new(keys.remove(0), reader); + let expected_sweep = [ + (transaction_ids[3], 1, 0), + (transaction_ids[0], 1, 1), + (transaction_ids[1], 2, 0), + (transaction_ids[2], 2, 1), + ] + .map(|(id, block_num, index)| (hex::encode(id.to_bytes()), block_num, index)); + + // Sweep asking for a single row per page: each page returns exactly one row, and the next page + // is the same request resumed one position past the row just reported. + let mut swept = Vec::new(); + let mut block_from = None; + let mut tx_index_from = None; + loop { + let response = list( + &service, + ListTransactionsQuery { + block_from, + tx_index_from, + limit: Some(1), + ..ListTransactionsQuery::default() + }, + ) + .await + .unwrap(); + assert_eq!(response.pagination.chain_tip, 2, "the tip is reported on every page"); + let (Some(last_block), Some(last_index)) = + (response.pagination.block_num, response.pagination.block_tx_index) + else { + assert!(response.transactions.is_empty(), "a page with rows must report its position"); + break; + }; + assert_eq!(response.transactions.len(), 1, "a page must honor the row limit"); + block_from = Some(last_block); + tx_index_from = Some(last_index + 1); + swept.extend( + response + .transactions + .into_iter() + .map(|item| (item.transaction_id, item.block_num, item.block_tx_index)), + ); + } + assert_eq!(swept, expected_sweep); + + // A block filter excludes block 1, and the never-committed transaction is absent regardless of + // the filter. + let response = list( + &service, + ListTransactionsQuery { + block_from: Some(2), + block_to: Some(9), + ..ListTransactionsQuery::default() + }, + ) + .await + .unwrap(); + assert_eq!( + response + .transactions + .iter() + .map(|item| item.transaction_id.as_str()) + .collect::>(), + vec![ + hex::encode(transaction_ids[1].to_bytes()), + hex::encode(transaction_ids[2].to_bytes()), + ], + ); +} + +#[tokio::test] +async fn list_rejects_invalid_parameters() { + let mut keys = operator_keys(); + let (_directory, _writer, reader) = test_database().await; + let service = ValidatorAdminService::new(keys.remove(0), reader); + + for query in [ + ListTransactionsQuery { + limit: Some(0), + ..ListTransactionsQuery::default() + }, + ListTransactionsQuery { + limit: Some(MAX_PAGE_LIMIT + 1), + ..ListTransactionsQuery::default() + }, + ListTransactionsQuery { + limit: Some(MAX_RECORD_PAGE_LIMIT + 1), + include_records: true, + ..ListTransactionsQuery::default() + }, + ListTransactionsQuery { + block_from: Some(3), + block_to: Some(2), + ..ListTransactionsQuery::default() + }, + ListTransactionsQuery { + tx_index_from: Some(1), + ..ListTransactionsQuery::default() + }, + ] { + let error = list(&service, query).await.unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + } +} + +#[tokio::test] +async fn get_transaction_returns_the_full_record() { + let mut keys = operator_keys(); + let (_directory, writer, reader) = test_database().await; + let transaction_id = TransactionId::from_raw(Word::from([6u32, 6, 6, 6])); + let record = target_record(&keys[0], transaction_id, 22, b"record"); + writer.insert_validated_private_transaction(record.clone()).await.unwrap(); + let service = ValidatorAdminService::new(keys.remove(0), reader); + + let Json(fetched) = get_validated_private_transaction( + State(service.clone()), + Path(hex::encode(transaction_id.to_bytes())), + ) + .await + .unwrap(); + assert_eq!(fetched.transaction_id, hex::encode(transaction_id.to_bytes())); + assert_eq!(fetched.final_ciphertext, hex::encode(record.encrypted_record())); + assert_eq!(fetched.cipher_nonce, hex::encode(record.nonce())); + assert_eq!(fetched.encrypted_record_key, hex::encode(record.encrypted_record_key())); + assert_eq!(fetched.decryption_context, hex::encode(record.context().to_bytes())); + + let unknown_id = TransactionId::from_raw(Word::from([9u32, 9, 9, 9])); + let error = get_validated_private_transaction( + State(service.clone()), + Path(hex::encode(unknown_id.to_bytes())), + ) + .await + .unwrap_err(); + assert_eq!(error.status, StatusCode::NOT_FOUND); + + let error = get_validated_private_transaction(State(service), Path("not hex".to_owned())) + .await + .unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn shares_for_different_ciphertexts_are_not_reusable() { + let mut keys = operator_keys(); + let record_owner = keys.pop().unwrap(); + let second = ValidatorAdminService::new(keys.pop().unwrap(), test_database().await.2); + let first = ValidatorAdminService::new(keys.pop().unwrap(), test_database().await.2); + let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4])); + let first_record = target_record(&record_owner, transaction_id, 2, b"same plaintext"); + let second_record = target_record(&record_owner, transaction_id, 3, b"same plaintext"); + assert_eq!(first_record.context(), second_record.context()); + assert_ne!(first_record.encrypted_record_key(), second_record.encrypted_record_key()); + + let shares = [ + issue(&first, share_request(&first_record)).await.unwrap().decryption_share, + issue(&second, share_request(&second_record)).await.unwrap().decryption_share, + ] + .map(|share| hex::decode(share).unwrap()); + let request = PrivateRecordShareRequest::for_record(&first_record); + let result = PrivateRecordCombiner::from_operator_key(&record_owner).unwrap().open( + &request, + &first_record, + &shares, + ); + + assert!(matches!(result, Err(PrivateRecordError::ShareCombination(_)))); +} + +#[tokio::test] +async fn invalid_share_requests_return_bad_request() { + let mut keys = operator_keys(); + let record = + target_record(&keys[0], TransactionId::from_raw(Word::from([1u32, 2, 3, 4])), 4, b"record"); + let context = record.context().to_bytes(); + let (_directory, _writer, reader) = test_database().await; + + let invalid_hex = IssueDecryptionShareRequest { + ciphertext: "not hex".to_owned(), + decryption_context: hex::encode(&context), + }; + let error = issue(&ValidatorAdminService::new(keys.remove(0), reader.clone()), invalid_hex) + .await + .unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + + let mut short_rng = ChaCha20Rng::from_seed([5; 32]); + let short_ciphertext = keys[0] + .sealing_key() + .seal_bytes_with_associated_data(&mut short_rng, &[0; 31], &context) + .unwrap(); + let wrong_size = IssueDecryptionShareRequest { + ciphertext: hex::encode(to_wire_bytes(&short_ciphertext)), + decryption_context: hex::encode(context), + }; + let error = issue(&ValidatorAdminService::new(keys.remove(0), reader.clone()), wrong_size) + .await + .unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + + let wrong_context = IssueDecryptionShareRequest { + ciphertext: hex::encode(record.encrypted_record_key()), + decryption_context: hex::encode(b"wrong context"), + }; + let error = + issue(&ValidatorAdminService::new(operator_keys().remove(0), reader), wrong_context) + .await + .unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn router_exposes_only_the_json_admin_routes() { + let (_directory, _writer, reader) = test_database().await; + let app = router(operator_keys().remove(0), reader); + + let response = app + .clone() + .oneshot(Request::get(LIST_TRANSACTIONS_PATH).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json",); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + body.as_ref(), + br#"{"transactions":[],"pagination":{"chain_tip":0,"block_num":null,"block_tx_index":null}}"# + ); + + let response = app + .clone() + .oneshot( + Request::get(format!("{LIST_TRANSACTIONS_PATH}/{}", hex::encode([1u8; 32]))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json",); + + let response = app + .clone() + .oneshot( + Request::post(ISSUE_SHARE_PATH) + .header("content-type", "application/json") + .body(Body::from(r#"{"ciphertext":"not hex","decryption_context":""}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = app.oneshot(Request::get("/").body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +}