diff --git a/Cargo.lock b/Cargo.lock index f835b0c7e8..bf0af484b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4051,6 +4051,7 @@ dependencies = [ "anyhow", "aws-config", "aws-sdk-kms", + "axum", "base64", "build-rs", "chacha20poly1305", @@ -4071,12 +4072,14 @@ dependencies = [ "rand 0.10.2", "rand_chacha 0.3.1", "rand_core 0.6.4", + "serde", "tempfile", "thiserror 2.0.19", "tokio", "tokio-stream", "tonic", "tonic-reflection", + "tower", "tower-http", "tracing", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index dfb5d2e57d..b2b084c825 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ miden-crypto = { version = "0.28" } # External dependencies anyhow = { version = "1.0" } assert_matches = { version = "1.5" } +axum = { version = "0.8" } backon = { version = "1.6" } build-rs = { version = "0.3" } chacha20poly1305 = { version = "0.11" } diff --git a/bin/network-monitor/Cargo.toml b/bin/network-monitor/Cargo.toml index dd79d4aa79..659a2fea3c 100644 --- a/bin/network-monitor/Cargo.toml +++ b/bin/network-monitor/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] anyhow = { workspace = true } -axum = { version = "0.8" } +axum = { workspace = true } backon = { workspace = true } clap = { features = ["env"], workspace = true } futures = { workspace = true } diff --git a/bin/validator/Cargo.toml b/bin/validator/Cargo.toml index 07121849f7..fe90c274d0 100644 --- a/bin/validator/Cargo.toml +++ b/bin/validator/Cargo.toml @@ -21,6 +21,7 @@ doctest = false anyhow = { workspace = true } aws-config = { version = "1.8.14" } aws-sdk-kms = { version = "1.100" } +axum = { workspace = true } base64 = { version = "0.22" } chacha20poly1305 = { workspace = true } clap = { features = ["env", "string"], workspace = true } @@ -37,6 +38,7 @@ miden-node-utils = { features = ["testing"], workspace = true } miden-protocol = { workspace = true } miden-tx = { features = ["concurrent"], workspace = true } rand_core_06 = { workspace = true } +serde = { workspace = true } thiserror = { workspace = true } tokio = { features = ["macros", "net", "rt-multi-thread"], workspace = true } tokio-stream = { features = ["net"], workspace = true } @@ -60,3 +62,4 @@ rand = { workspace = true } rand_chacha_03 = { workspace = true } tempfile = { workspace = true } tokio = { features = ["macros", "rt-multi-thread", "sync"], workspace = true } +tower = { features = ["util"], workspace = true } diff --git a/bin/validator/src/commands/issue_private_record_share.rs b/bin/validator/src/commands/issue_private_record_share.rs index 42c4ae5c18..7f1baac772 100644 --- a/bin/validator/src/commands/issue_private_record_share.rs +++ b/bin/validator/src/commands/issue_private_record_share.rs @@ -27,10 +27,8 @@ pub(super) fn issue( .context("private record bundle is invalid")?; let request = PrivateRecordShareRequest::for_record(&record); - // Running this filesystem-restricted command is the demo's explicit release decision. - let allow = |_: &PrivateRecordShareRequest, _: &miden_validator::StoredPrivateRecord| true; let share = operator_key - .issue_private_record_share(&mut OsRng, &request, &record, &allow) + .issue_private_record_share(&mut OsRng, &request, &record) .context("failed to issue private record share")?; write_share(output, &share) diff --git a/bin/validator/src/commands/mod.rs b/bin/validator/src/commands/mod.rs index bc7ca059d7..40beb97c9b 100644 --- a/bin/validator/src/commands/mod.rs +++ b/bin/validator/src/commands/mod.rs @@ -24,7 +24,6 @@ use miden_validator::{ GoldenOperatorKey, LOG_TARGET, LocalX25519TransactionInputDecrypter, - PrivateRecordSealer, StorageKeyEpoch, TransactionInputDecrypter, ValidatorSigner, @@ -32,6 +31,7 @@ use miden_validator::{ const ENV_DATA_DIRECTORY: &str = "MIDEN_VALIDATOR_DATA_DIRECTORY"; const ENV_LISTEN: &str = "MIDEN_VALIDATOR_LISTEN"; +const ENV_ADMIN_LISTEN: &str = "MIDEN_VALIDATOR_ADMIN_LISTEN"; const ENV_SIGNING_KEY: &str = "MIDEN_VALIDATOR_SIGNING_KEY"; const ENV_SIGNING_KEY_KMS_ID: &str = "MIDEN_VALIDATOR_SIGNING_KEY_KMS_ID"; const ENV_ENCRYPTION_KEY: &str = "MIDEN_VALIDATOR_ENCRYPTION_KEY"; @@ -170,6 +170,10 @@ pub enum ValidatorCommand { #[arg(long = "listen", env = ENV_LISTEN, value_name = "LISTEN")] listen: std::net::SocketAddr, + /// Socket address at which to serve the private administration API. + #[arg(long = "admin.listen", env = ENV_ADMIN_LISTEN, value_name = "LISTEN")] + admin_listen: Option, + #[command(flatten)] grpc_options: GrpcOptionsInternal, @@ -292,6 +296,7 @@ impl ValidatorCommand { Self::ExportPrivateRecord(options) => export_private_record::export(options).await, Self::Start { listen, + admin_listen, grpc_options, signing_key, data_directory, @@ -303,14 +308,17 @@ impl ValidatorCommand { .. } => { let address = listen; - let private_record_sealer = - PrivateRecordSealer::from_operator_key(&storage_key.load()?); + let operator_key = storage_key.load()?; tracing::info!( target: miden_validator::LOG_TARGET, { service.name = "miden-validator", service.version = env!("CARGO_PKG_VERSION"), validator.listen = %address, + validator.admin_listen = admin_listen.map_or_else( + || "disabled".to_owned(), + |address| address.to_string(), + ), data.directory = %data_directory.display(), validator.signer = if signing_key_kms_id.is_some() { "kms" } else { "local" }, sqlite.connection_pool_size = sqlite_connection_pool_size.get(), @@ -326,8 +334,9 @@ impl ValidatorCommand { start::start( address, + admin_listen, grpc_options, - start::ValidatorKeys { signer, decrypter, private_record_sealer }, + start::ValidatorKeys { signer, decrypter, operator_key }, data_directory, sqlite_connection_pool_size, shutdown, @@ -675,6 +684,22 @@ mod tests { assert_eq!(encryption_key_kms_ciphertext, None); } + #[test] + fn admin_listener_is_opt_in() { + let command = parse_start(&[]).expect("start without an admin listener must parse"); + let ValidatorCommand::Start { admin_listen, .. } = command else { + panic!("expected the start command"); + }; + assert_eq!(admin_listen, None); + + let command = parse_start(&["--admin.listen", "127.0.0.1:50102"]) + .expect("start with an admin listener must parse"); + let ValidatorCommand::Start { admin_listen, .. } = command else { + panic!("expected the start command"); + }; + assert_eq!(admin_listen, Some("127.0.0.1:50102".parse().unwrap())); + } + #[test] fn encryption_key_kms_ciphertext_parses_alone() { let command = parse_start(&["--encryption-key.kms-ciphertext", "deadbeef"]) diff --git a/bin/validator/src/commands/start.rs b/bin/validator/src/commands/start.rs index ade165a757..c135b7e70b 100644 --- a/bin/validator/src/commands/start.rs +++ b/bin/validator/src/commands/start.rs @@ -6,10 +6,13 @@ use std::sync::Arc; use anyhow::Context; use miden_node_utils::clap::GrpcOptionsInternal; use miden_node_utils::shutdown::CancellationToken; +use miden_node_utils::tasks::Tasks; use miden_validator::{ DataDirectory, + GoldenOperatorKey, PrivateRecordSealer, TransactionInputDecrypter, + ValidatorAdminServer, ValidatorServer, ValidatorSigner, }; @@ -17,12 +20,13 @@ use miden_validator::{ pub(crate) struct ValidatorKeys { pub(crate) signer: ValidatorSigner, pub(crate) decrypter: Arc, - pub(crate) private_record_sealer: PrivateRecordSealer, + pub(crate) operator_key: GoldenOperatorKey, } // Starts the validator component. pub async fn start( address: SocketAddr, + admin_address: Option, grpc_options: GrpcOptionsInternal, keys: ValidatorKeys, data_directory: PathBuf, @@ -31,16 +35,36 @@ pub async fn start( ) -> anyhow::Result<()> { let data_directory = DataDirectory::load(data_directory).context("failed to load validator data directory")?; - ValidatorServer { + let database = miden_validator::db::load_with_pool_size( + data_directory.database_path(), + sqlite_connection_pool_size, + ) + .await + .context("failed to initialize validator database")?; + let private_record_sealer = PrivateRecordSealer::from_operator_key(&keys.operator_key); + let public_server = ValidatorServer { address, grpc_options, signer: keys.signer, decrypter: keys.decrypter, - private_record_sealer: keys.private_record_sealer, + private_record_sealer, data_directory, - sqlite_connection_pool_size, + database: database.clone(), + }; + + let mut tasks = Tasks::new(); + tasks.spawn("validator public API", public_server.serve(shutdown.clone())); + if let Some(address) = admin_address { + let admin_server = ValidatorAdminServer { + address, + operator_key: keys.operator_key, + database, + }; + tasks.spawn("validator admin API", admin_server.serve(shutdown.clone())); } - .serve(shutdown) - .await - .context("failed while serving validator component") + + tasks + .join_next_or_cancelled(shutdown) + .await + .context("failed while serving validator component") } diff --git a/bin/validator/src/db/migrations.rs b/bin/validator/src/db/migrations.rs index 34d9109a10..0dd7577af7 100644 --- a/bin/validator/src/db/migrations.rs +++ b/bin/validator/src/db/migrations.rs @@ -71,7 +71,7 @@ mod tests { use super::*; const EXPECTED_SCHEMA_HASHES: [SchemaHash; 1] = [SchemaHash::from_hex( - "384a849131983267a3a8b61d170dae7bcbec535eb4716fea7812024073f569cf", + "f2f6af5e22d8d0273524a227417339279d1a694c3802a8f3f7cc4b31e21ee035", )]; #[test] diff --git a/bin/validator/src/db/migrations/001_initial.sql b/bin/validator/src/db/migrations/001_initial.sql index 2ef033c174..10588283af 100644 --- a/bin/validator/src/db/migrations/001_initial.sql +++ b/bin/validator/src/db/migrations/001_initial.sql @@ -1,6 +1,8 @@ CREATE TABLE validated_transactions ( + -- Rowid-backed local insertion order used by the private administration API. + insertion_sequence INTEGER PRIMARY KEY AUTOINCREMENT, -- Transaction ID, unique within this validator's database. - id BLOB NOT NULL, + id BLOB NOT NULL UNIQUE, -- Signing public key of the validator that produced this record. validator_id BLOB NOT NULL, -- Genesis commitment of the network that produced the transaction. @@ -17,7 +19,6 @@ CREATE TABLE validated_transactions ( encrypted_record BLOB NOT NULL, -- Golden EHTDH1 encryption of the key for encrypted_record. encrypted_record_key BLOB NOT NULL, - PRIMARY KEY (id), CHECK (length(id) = 32), CHECK (length(validator_id) = 33), CHECK (length(chain_id) = 32), @@ -27,7 +28,7 @@ CREATE TABLE validated_transactions ( CHECK (length(cipher_nonce) = 24), CHECK (length(encrypted_record) >= 16), CHECK (length(encrypted_record_key) > 0) -) WITHOUT ROWID; +); CREATE INDEX idx_validated_transactions_key_epoch ON validated_transactions(key_epoch); diff --git a/bin/validator/src/db/mod.rs b/bin/validator/src/db/mod.rs index bbe50f743d..662d5f7cdb 100644 --- a/bin/validator/src/db/mod.rs +++ b/bin/validator/src/db/mod.rs @@ -30,6 +30,7 @@ mod sql { include_str!("sql/load_private_records_by_key_epoch.sql"); pub(super) const LOAD_PRIVATE_RECORDS_BY_SETUP_CONTEXT: &str = include_str!("sql/load_private_records_by_setup_context.sql"); + pub(super) const LOAD_ALL_TRANSACTIONS: &str = include_str!("sql/load_all_transactions.sql"); pub(super) const TRANSACTION_EXISTS: &str = include_str!("sql/transaction_exists.sql"); pub(super) const UPSERT_BLOCK_HEADER: &str = include_str!("sql/upsert_block_header.sql"); pub(super) const LOAD_CHAIN_TIP: &str = include_str!("sql/load_chain_tip.sql"); @@ -188,6 +189,13 @@ pub fn load_private_records_by_setup_context( ) } +/// Loads all validated private transactions in insertion order. +pub(crate) fn load_all_transactions( + tx: &ReadTx<'_>, +) -> Result, DatabaseError> { + tx.query(sql::LOAD_ALL_TRANSACTIONS, &[], private_record_from_row) +} + fn private_record_from_row(row: &Row<'_>) -> Result { let chain_id = fixed_32(row.get(0)?, "private record chain id")?; let key_epoch = fixed_32(row.get(1)?, "private record key epoch")?; @@ -483,6 +491,34 @@ 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.write("insert private record", move |tx| { + insert_validated_private_transaction(tx, &record) + }) + .await + .unwrap(); + } + + let loaded = db.read("load all transactions", load_all_transactions).await.unwrap(); + + assert_eq!(loaded, records); + } + #[tokio::test] async fn stored_private_record_opens_with_threshold_shares() { let temp_dir = tempfile::tempdir().expect("failed to create temp directory"); @@ -507,15 +543,14 @@ mod tests { .unwrap() .unwrap(); let request = PrivateRecordShareRequest::for_record(&stored); - let allow = |_: &PrivateRecordShareRequest, _: &StoredPrivateRecord| true; let mut first_rng = ChaCha20Rng::from_seed([41; 32]); let mut second_rng = ChaCha20Rng::from_seed([42; 32]); let shares = [ operators[0] - .issue_private_record_share(&mut first_rng, &request, &stored, &allow) + .issue_private_record_share(&mut first_rng, &request, &stored) .unwrap(), operators[1] - .issue_private_record_share(&mut second_rng, &request, &stored, &allow) + .issue_private_record_share(&mut second_rng, &request, &stored) .unwrap(), ]; @@ -545,7 +580,8 @@ mod tests { .unwrap() .join("\n"); - assert!(schema.contains("PRIMARY KEY (id)")); + assert!(schema.contains("insertion_sequence INTEGER PRIMARY KEY AUTOINCREMENT")); + assert!(schema.contains("id BLOB NOT NULL UNIQUE")); assert!(schema.contains("idx_validated_transactions_key_epoch")); assert!(schema.contains("idx_validated_transactions_setup_context_id")); } diff --git a/bin/validator/src/db/sql/load_all_transactions.sql b/bin/validator/src/db/sql/load_all_transactions.sql new file mode 100644 index 0000000000..b6dc8ff24b --- /dev/null +++ b/bin/validator/src/db/sql/load_all_transactions.sql @@ -0,0 +1,12 @@ +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/lib.rs b/bin/validator/src/lib.rs index 4c0defaee5..c3e67aadd5 100644 --- a/bin/validator/src/lib.rs +++ b/bin/validator/src/lib.rs @@ -15,12 +15,11 @@ pub use private_record::{ PrivateRecordError, PrivateRecordId, PrivateRecordSealer, - PrivateRecordSharePolicy, PrivateRecordShareRequest, PrivateRecordStorageFields, StoredPrivateRecord, }; -pub use server::ValidatorServer; +pub use server::{ValidatorAdminServer, ValidatorServer}; pub use signers::{ KmsSigner, LocalX25519TransactionInputDecrypter, diff --git a/bin/validator/src/private_record.rs b/bin/validator/src/private_record.rs index 0810ec6efa..a0fad43fdf 100644 --- a/bin/validator/src/private_record.rs +++ b/bin/validator/src/private_record.rs @@ -22,7 +22,7 @@ pub const PRIVATE_RECORD_FORMAT_V1: u32 = 1; const CONTEXT_DOMAIN_V1: &[u8] = b"miden-private-record-context-v1"; const PRIVATE_RECORD_BUNDLE_MAGIC: &[u8] = b"miden-private-record-bundle-v1"; -const CONTENT_KEY_BYTES: usize = 32; +pub(crate) const CONTENT_KEY_BYTES: usize = 32; const NONCE_BYTES: usize = 24; const TAG_BYTES: usize = 16; const VALIDATOR_ID_BYTES: usize = 33; @@ -175,21 +175,6 @@ impl PrivateRecordShareRequest { } } -/// Decides whether an operator may issue a decryption share. -pub trait PrivateRecordSharePolicy { - /// Returns `true` when the request may receive a share. - fn allows(&self, request: &PrivateRecordShareRequest, record: &StoredPrivateRecord) -> bool; -} - -impl PrivateRecordSharePolicy for F -where - F: Fn(&PrivateRecordShareRequest, &StoredPrivateRecord) -> bool, -{ - fn allows(&self, request: &PrivateRecordShareRequest, record: &StoredPrivateRecord) -> bool { - self(request, record) - } -} - /// Public Golden key used to seal private records for one epoch. #[derive(Clone, Debug)] pub struct PrivateRecordSealer { @@ -570,9 +555,6 @@ pub enum PrivateRecordError { /// The request does not carry the record's exact canonical context. #[error("private record decryption context does not match the record")] DecryptionContextMismatch, - /// Policy denied the share request. - #[error("private record share request was denied")] - ShareDenied, /// The authenticated record cipher failed. #[error("failed to encrypt private record")] RecordEncryption, @@ -653,14 +635,6 @@ mod tests { test_private_record_sealer(EPOCH, [8; 32]) } - fn allow_all(_request: &PrivateRecordShareRequest, _record: &StoredPrivateRecord) -> bool { - true - } - - fn deny_all(_request: &PrivateRecordShareRequest, _record: &StoredPrivateRecord) -> bool { - false - } - fn threshold_record( operator_key: &GoldenOperatorKey, transaction_id: TransactionId, @@ -681,9 +655,7 @@ mod tests { seed: u8, ) -> Vec { let mut rng = ChaCha20Rng::from_seed([seed; 32]); - operator_key - .issue_private_record_share(&mut rng, request, record, &allow_all) - .unwrap() + operator_key.issue_private_record_share(&mut rng, request, record).unwrap() } fn transaction_inputs() -> TransactionInputs { @@ -881,17 +853,6 @@ mod tests { let plaintext = inputs.to_bytes(); let record = threshold_record(&operator_keys[0], transaction_id(), 20, &plaintext); let request = PrivateRecordShareRequest::for_record(&record); - let mut denied_rng = ChaCha20Rng::from_seed([21; 32]); - - assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut denied_rng, - &request, - &record, - &deny_all, - ), - Err(PrivateRecordError::ShareDenied), - )); let shares = [ issue_share(&operator_keys[0], &request, &record, 22), @@ -946,12 +907,7 @@ mod tests { ); let mut rng = ChaCha20Rng::from_seed([25; 32]); assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut rng, - &wrong_transaction, - &record, - &allow_all, - ), + operator_keys[0].issue_private_record_share(&mut rng, &wrong_transaction, &record,), Err(PrivateRecordError::RecordIdMismatch), )); @@ -961,12 +917,7 @@ mod tests { request.context().to_vec(), ); assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut rng, - &wrong_epoch, - &record, - &allow_all, - ), + operator_keys[0].issue_private_record_share(&mut rng, &wrong_epoch, &record,), Err(PrivateRecordError::KeyEpochMismatch), )); @@ -978,12 +929,7 @@ mod tests { wrong_context_bytes, ); assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut rng, - &wrong_context, - &record, - &allow_all, - ), + operator_keys[0].issue_private_record_share(&mut rng, &wrong_context, &record,), Err(PrivateRecordError::DecryptionContextMismatch), )); @@ -991,12 +937,7 @@ mod tests { wrong_setup_fields.setup_context_id = [99; 32]; let wrong_setup = StoredPrivateRecord::from_storage_fields(wrong_setup_fields).unwrap(); assert!(matches!( - operator_keys[0].issue_private_record_share( - &mut rng, - &request, - &wrong_setup, - &allow_all, - ), + operator_keys[0].issue_private_record_share(&mut rng, &request, &wrong_setup,), Err(PrivateRecordError::SetupContextMismatch), )); } diff --git a/bin/validator/src/server/admin_service.rs b/bin/validator/src/server/admin_service.rs new file mode 100644 index 0000000000..14435bfdeb --- /dev/null +++ b/bin/validator/src/server/admin_service.rs @@ -0,0 +1,441 @@ +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_node_db::sqlite::Database; +use miden_protocol::utils::serde::Serializable; +use rand_core_06::OsRng; +use serde::{Deserialize, Serialize}; + +use crate::db::load_all_transactions; +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, + database: Database, +} + +impl ValidatorAdminService { + fn new(operator_key: GoldenOperatorKey, database: Database) -> Self { + Self { + operator_key: Arc::new(operator_key), + database, + } + } +} + +pub(super) fn router(operator_key: GoldenOperatorKey, database: Database) -> 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, database)) +} + +#[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 + .database + .read("list validated private transactions", 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::insert_validated_private_transaction; + 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, Database) { + let directory = tempfile::tempdir().unwrap(); + let database = crate::db::setup(directory.path().join("validator.sqlite3")).await.unwrap(); + (directory, database) + } + + 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, database) = test_database().await; + let first_service = ValidatorAdminService::new(first, database.clone()); + let second_service = ValidatorAdminService::new(second, database.clone()); + 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()); + let stored_record = record.clone(); + database + .write("store listed private transaction", move |tx| { + insert_validated_private_transaction(tx, &stored_record) + }) + .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, database) = 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"); + database + .write("store private transaction", move |tx| { + insert_validated_private_transaction(tx, &record) + }) + .await + .unwrap(); + } + + let service = ValidatorAdminService::new(keys.remove(0), database); + 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.1); + let first = ValidatorAdminService::new(keys.pop().unwrap(), test_database().await.1); + 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, database) = 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), database.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), database.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), database), 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, database) = test_database().await; + let app = router(operator_keys().remove(0), database); + + 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/mod.rs b/bin/validator/src/server/mod.rs index fd71f347b6..8f0a85ff60 100644 --- a/bin/validator/src/server/mod.rs +++ b/bin/validator/src/server/mod.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; -use std::num::NonZeroUsize; use anyhow::Context; +use miden_node_db::sqlite::Database; use miden_node_proto::server::validator_api; use miden_node_proto_build::validator_api_descriptor; use miden_node_store::BlockStore; @@ -14,20 +14,17 @@ use tokio_stream::wrappers::TcpListenerStream; use tower_http::catch_panic::CatchPanicLayer; use tower_http::trace::TraceLayer; -use crate::db::{ - count_signed_blocks, - count_validated_transactions, - load_chain_tip, - load_with_pool_size, -}; +use crate::db::{count_signed_blocks, count_validated_transactions, load_chain_tip}; use crate::{ DataDirectory, + GoldenOperatorKey, LOG_TARGET, PrivateRecordSealer, TransactionInputDecrypter, ValidatorSigner, }; +mod admin_service; mod validator_service; use validator_service::{InitialMetrics, ValidatorService}; @@ -59,8 +56,49 @@ pub struct ValidatorServer { /// The data directory for the validator component's database files. pub data_directory: DataDirectory, - /// Maximum number of SQLite connections in the validator database connection pool. - pub sqlite_connection_pool_size: NonZeroUsize, + /// Shared validator database. + pub database: Database, +} + +/// Serves the private validator administration API on a network-isolated listener. +pub struct ValidatorAdminServer { + /// Address of the private administration listener. + pub address: SocketAddr, + /// Golden key material used to issue this validator's decryption shares. + pub operator_key: GoldenOperatorKey, + /// Shared validator database. + pub database: Database, +} + +impl ValidatorAdminServer { + /// Serves the private validator administration API. + pub async fn serve(self, shutdown: CancellationToken) -> anyhow::Result<()> { + let listener = + TcpListener::bind(self.address).await.context("failed to bind admin address")?; + self.serve_on(listener, shutdown).await + } + + async fn serve_on( + self, + listener: TcpListener, + shutdown: CancellationToken, + ) -> anyhow::Result<()> { + let endpoint = + listener.local_addr().context("failed to read validator admin listen address")?; + tracing::info!( + target: LOG_TARGET, + { + service.name = "miden-validator-admin", + validator.admin_listen = %endpoint, + }, + "Validator admin server ready", + ); + + axum::serve(listener, admin_service::router(self.operator_key, self.database)) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await + .context("failed to serve validator admin API") + } } impl ValidatorServer { @@ -69,13 +107,7 @@ impl ValidatorServer { /// Executes in place (i.e. not spawned) and will run indefinitely until a fatal error is /// encountered. pub async fn serve(self, shutdown: CancellationToken) -> anyhow::Result<()> { - // Initialize database connection. - let db = load_with_pool_size( - self.data_directory.database_path(), - self.sqlite_connection_pool_size, - ) - .await - .context("failed to initialize validator database")?; + let db = self.database; // Initialize block store. let block_store = BlockStore::load(self.data_directory.block_store_dir()) diff --git a/bin/validator/src/storage_key.rs b/bin/validator/src/storage_key.rs index 378e3108bd..d2511a0f8a 100644 --- a/bin/validator/src/storage_key.rs +++ b/bin/validator/src/storage_key.rs @@ -3,6 +3,7 @@ use std::fmt; use golden_core::{GoldenGroup, ParticipantIndex}; use golden_ehtdh1::wire::{from_wire_bytes, to_wire_bytes}; use golden_ehtdh1::{ + Ciphertext, PublicKeySet, SealingKey, SecretShare, @@ -14,12 +15,8 @@ use golden_halo2curves::golden_group::Secp256k1GoldenGroup; use rand_core_06::{CryptoRng, RngCore}; use zeroize::Zeroizing; -use crate::{ - PrivateRecordError, - PrivateRecordSharePolicy, - PrivateRecordShareRequest, - StoredPrivateRecord, -}; +use crate::private_record::CONTENT_KEY_BYTES; +use crate::{PrivateRecordError, PrivateRecordShareRequest, StoredPrivateRecord}; /// Golden group used for validator storage keys. type StorageGroup = Secp256k1GoldenGroup; @@ -223,25 +220,25 @@ impl GoldenOperatorKey { self.secret_share.participant } - /// Checks one private-record request and returns a canonical decryption share. - pub fn issue_private_record_share( + /// Issues a canonical decryption share for one encrypted content key and exact context. + pub(crate) fn issue_decryption_share( &self, rng: &mut R, - request: &PrivateRecordShareRequest, - record: &StoredPrivateRecord, - policy: &P, + ciphertext_bytes: &[u8], + context: &[u8], ) -> Result, PrivateRecordError> where R: RngCore + CryptoRng, - P: PrivateRecordSharePolicy + ?Sized, { - record.validate_share_request(request, self.key_epoch, self.setup_context_id())?; - if !policy.allows(request, record) { - return Err(PrivateRecordError::ShareDenied); + let ciphertext: Ciphertext = + from_wire_bytes(ciphertext_bytes).map_err(PrivateRecordError::InvalidGoldenEncoding)?; + if ciphertext.encrypted_payload.len() != CONTENT_KEY_BYTES { + return Err(PrivateRecordError::InvalidEncryptedRecordKey); } + ciphertext + .verify_with_associated_data(context) + .map_err(PrivateRecordError::InvalidGoldenEncoding)?; - let ciphertext = record.decode_encrypted_record_key()?; - let context = request.context(); let share = UnsealingShare::new(self.secret_share.clone()) .decrypt_share_with_associated_data( rng, @@ -253,6 +250,20 @@ impl GoldenOperatorKey { .map_err(PrivateRecordError::ShareGeneration)?; Ok(to_wire_bytes(&share)) } + + /// Checks one private-record request and returns a canonical decryption share. + pub fn issue_private_record_share( + &self, + rng: &mut R, + request: &PrivateRecordShareRequest, + record: &StoredPrivateRecord, + ) -> Result, PrivateRecordError> + where + R: RngCore + CryptoRng, + { + record.validate_share_request(request, self.key_epoch, self.setup_context_id())?; + self.issue_decryption_share(rng, record.encrypted_record_key(), request.context()) + } } /// Error raised while loading a Golden operator key. diff --git a/compose/validator.yml b/compose/validator.yml index cbe4f1b45b..539237e9e3 100644 --- a/compose/validator.yml +++ b/compose/validator.yml @@ -16,6 +16,7 @@ x-validator: &validator - miden-validator - start - --listen=0.0.0.0:50101 + - --admin.listen=0.0.0.0:50102 services: validator-1: