diff --git a/Cargo.lock b/Cargo.lock index 8eabcea40e..76ee2e7115 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4240,6 +4240,7 @@ name = "miden-node-rpc" version = "0.17.0-rc.1" dependencies = [ "anyhow", + "fs-err", "futures", "http 1.5.0", "mediatype", diff --git a/bin/node/src/admin/tests.rs b/bin/node/src/admin/tests.rs index d564867481..ef4354b7a8 100644 --- a/bin/node/src/admin/tests.rs +++ b/bin/node/src/admin/tests.rs @@ -79,7 +79,7 @@ async fn admin_registration_workflow() { assert_eq!(registered["allowlisted_at"], unused["allowlisted_at"]); assert!(registered.get("invitation_code").is_none()); assert_eq!( - allowlist.invitation_status(InvitationCode::new(b"abc").unwrap()).await.unwrap(), + allowlist.invitation_status(InvitationCode::new("abc").unwrap()).await.unwrap(), miden_node_store::allowlist::InvitationStatus::Registered(account(0)) ); diff --git a/bin/node/src/commands/lifecycle.rs b/bin/node/src/commands/lifecycle.rs index 8216844c50..648e12c195 100644 --- a/bin/node/src/commands/lifecycle.rs +++ b/bin/node/src/commands/lifecycle.rs @@ -100,8 +100,8 @@ impl MigrateCommand { Db::migrate(data_directory.database_path()) .context("failed to apply store database migrations")?; - // Only sequencer admin startup creates this optional database. Migration must also work for - // full nodes that do not have it. + // Only sequencer startup creates this optional database. Migration must also work for full + // nodes that do not have it. let allowlist_path = data_directory.allowlist_database_path(); if fs_err::exists(&allowlist_path).context("failed to check account allowlist database")? { AccountAllowlist::migrate(allowlist_path) diff --git a/bin/node/src/commands/modes.rs b/bin/node/src/commands/modes.rs index b1d03bec5c..17ccf20169 100644 --- a/bin/node/src/commands/modes.rs +++ b/bin/node/src/commands/modes.rs @@ -75,6 +75,7 @@ impl SequencerCommand { remote_prover_monitor(self.block_producer.batch.prover_url.as_ref())?; let block_prover_monitor = remote_prover_monitor(self.block_producer.block_prover.url.as_ref())?; + let allowlist = Arc::new(self.load_allowlist()?); let (state, block_writer, proof_writer, writer_task) = load_state(&runtime, shutdown.clone()).await?; let _disk_monitor = state.spawn_disk_monitor(shutdown.clone()); @@ -102,7 +103,11 @@ impl SequencerCommand { let rpc = Rpc { listener: bind_rpc(runtime.rpc_listen).await?, state: Arc::clone(&state), - mode: RpcMode::sequencer(block_producer.clone(), validator_clients), + mode: RpcMode::sequencer( + block_producer.clone(), + validator_clients, + Arc::clone(&allowlist), + ), ntx_builder: Some(ntx_builder_client), grpc_options: runtime.grpc_options, network_tx_auth, @@ -114,22 +119,6 @@ impl SequencerCommand { if let Some(address) = self.admin_listen { let shutdown = shutdown.clone(); tasks.spawn("sequencer admin API", async move { - let data_directory = DataDirectory::load(runtime.data_directory)?; - // Chain bootstrap does not create the optional allowlist database. A promoted full - // node can reach startup without it. - // - // This is okay because this is a temporary database. - let allowlist_path = data_directory.allowlist_database_path(); - if !fs_err::exists(&allowlist_path) - .context("failed to check account allowlist database")? - { - AccountAllowlist::bootstrap(&allowlist_path) - .context("failed to bootstrap account allowlist database")?; - } - let allowlist = Arc::new( - AccountAllowlist::load(allowlist_path) - .context("failed to load account allowlist database")?, - ); AdminServer::bind(address, allowlist).await?.serve(shutdown).await }); } @@ -170,6 +159,18 @@ impl SequencerCommand { tasks.join_next_or_cancelled(shutdown).await } + fn load_allowlist(&self) -> anyhow::Result { + let data_directory = DataDirectory::load(self.runtime.data_directory.clone())?; + // Chain bootstrap does not create the allowlist database. A promoted full node can reach + // sequencer startup without it. + let allowlist_path = data_directory.allowlist_database_path(); + if !fs_err::exists(&allowlist_path).context("failed to check account allowlist database")? { + AccountAllowlist::bootstrap(&allowlist_path) + .context("failed to bootstrap account allowlist database")?; + } + AccountAllowlist::load(allowlist_path).context("failed to load account allowlist database") + } + fn log_starting(&self) { info!( target: crate::LOG_TARGET, diff --git a/crates/proto/build.rs b/crates/proto/build.rs index 236e1a1f9b..285de03ea3 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -61,6 +61,7 @@ fn generate_bindings(file_descriptors: &FileDescriptorSet, dst_dir: &Path) -> mi for &(proto_path, rust_path) in miden_objects::EXTERN_PATHS { prost_config.extern_path(proto_path, rust_path); } + prost_config.skip_debug(["RegisterAccountRequest"]); // Generate the stub of the user facing server from its proto file tonic_prost_build::configure() diff --git a/crates/proto/src/domain/account.rs b/crates/proto/src/domain/account.rs index eeafea4f2d..2a7a64ce61 100644 --- a/crates/proto/src/domain/account.rs +++ b/crates/proto/src/domain/account.rs @@ -1,3 +1,5 @@ +use std::fmt::{Debug, Formatter}; + use miden_node_utils::limiter::{QueryParamLimiter, QueryParamStorageMapKeyTotalLimit}; use miden_protocol::Word; #[cfg(test)] @@ -44,6 +46,17 @@ pub struct AccountInfo { pub details: Option, } +// REGISTER ACCOUNT REQUEST +// ================================================================================================ + +impl Debug for proto::rpc::RegisterAccountRequest { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegisterAccountRequest") + .field("account_id", &self.account_id) + .finish_non_exhaustive() + } +} + // ACCOUNT REQUEST // ================================================================================================ diff --git a/crates/proto/src/domain/account/tests.rs b/crates/proto/src/domain/account/tests.rs index 4646eb35d1..6efef3a0c7 100644 --- a/crates/proto/src/domain/account/tests.rs +++ b/crates/proto/src/domain/account/tests.rs @@ -2,6 +2,17 @@ use miden_protocol::account::StorageMapKey; use super::*; +#[test] +fn registration_request_debug_hides_invitation_code() { + let code = "private invitation code"; + let request = proto::rpc::RegisterAccountRequest { + invitation_code: code.to_owned(), + account_id: None, + }; + let debug = format!("{request:?}"); + assert!(!debug.contains(code)); +} + fn word_from_u32(arr: [u32; 4]) -> Word { Word::from(arr) } diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 98cd700abb..24b7e1cd7c 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -45,6 +45,7 @@ tower-http = { features = ["trace"], workspace = true } url = { workspace = true } [dev-dependencies] +fs-err = { workspace = true } miden-node-tracing = { features = ["tracing-forest"], workspace = true } miden-node-utils = { features = ["testing-prover"], workspace = true } miden-protocol = { default-features = true, features = ["testing"], workspace = true } diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index 63c4ac3de2..b09ea0bde5 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -82,6 +82,7 @@ mod get_network_note_status; mod get_note_script_by_root; mod get_notes_by_id; mod get_transaction_encryption_key; +mod register_account; mod status; mod submit_auth_tx; mod submit_auth_tx_batch; diff --git a/crates/rpc/src/server/api/register_account.rs b/crates/rpc/src/server/api/register_account.rs new file mode 100644 index 0000000000..e4015a09e5 --- /dev/null +++ b/crates/rpc/src/server/api/register_account.rs @@ -0,0 +1,64 @@ +use miden_node_proto::generated as proto; +use miden_node_store::allowlist::{AllowlistError, InvitationCode}; +use miden_node_tracing::{ErrorReport, miden_instrument, miden_span_record}; +use miden_protocol::account::AccountId; +use tonic::{Code, Request, Status}; + +use super::{RpcBackend, RpcService}; +use crate::COMPONENT; + +#[tonic::async_trait] +impl proto::server::rpc_api::RegisterAccount for RpcService { + type Input = proto::rpc::RegisterAccountRequest; + type Output = (); + + fn decode(request: proto::rpc::RegisterAccountRequest) -> tonic::Result { + Ok(request) + } + + fn encode((): Self::Output) -> tonic::Result<()> { + Ok(()) + } + + #[miden_instrument(target = COMPONENT, name = "register_account", err)] + async fn handle( + &self, + request: Self::Input, + metadata: &tonic::metadata::MetadataMap, + _extensions: &tonic::codegen::http::Extensions, + ) -> tonic::Result { + let account_id: AccountId = request + .account_id + .clone() + .ok_or_else(|| Status::invalid_argument("missing account_id"))? + .try_into() + .map_err(|_| Status::invalid_argument("invalid account_id"))?; + miden_span_record!(account.id = account_id); + + let invitation = InvitationCode::new(&request.invitation_code) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + + match &self.backend { + RpcBackend::Sequencer { allowlist, .. } => { + allowlist.register_account(invitation, account_id).await.map(|_| ()).map_err( + |error| { + let code = match &error { + AllowlistError::InvitationNotFound => Code::NotFound, + AllowlistError::InvitationAlreadyUsed + | AllowlistError::AccountAlreadyRegistered(_) => Code::AlreadyExists, + AllowlistError::Database(_) => Code::Internal, + }; + Status::new(code, error.as_report()) + }, + ) + }, + RpcBackend::FullNode { source_rpc, .. } => { + let mut request = Request::new(request); + if let Some(accept) = metadata.get(http::header::ACCEPT.as_str()) { + request.metadata_mut().insert(http::header::ACCEPT.as_str(), accept.clone()); + } + source_rpc.as_ref().clone().register_account(request).await.map(|_| ()) + }, + } + } +} diff --git a/crates/rpc/src/server/api/submit_proven_tx.rs b/crates/rpc/src/server/api/submit_proven_tx.rs index 5730a0cc5a..acd4908ef9 100644 --- a/crates/rpc/src/server/api/submit_proven_tx.rs +++ b/crates/rpc/src/server/api/submit_proven_tx.rs @@ -125,7 +125,7 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService { })??; match &self.backend { - RpcBackend::Sequencer { block_producer, validators } => { + RpcBackend::Sequencer { block_producer, validators, .. } => { submit_tx_to_validators(validators.as_slice(), &request).await?; block_producer .submit_proven_tx(rebuilt_tx) diff --git a/crates/rpc/src/server/api/submit_proven_tx_batch.rs b/crates/rpc/src/server/api/submit_proven_tx_batch.rs index 99a73fa7fb..b1732b2836 100644 --- a/crates/rpc/src/server/api/submit_proven_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_proven_tx_batch.rs @@ -117,7 +117,7 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { verify_batch_proof(proven_batch, &proposed_batch).await?; match &self.backend { - RpcBackend::Sequencer { block_producer, validators } => { + RpcBackend::Sequencer { block_producer, validators, .. } => { submit_batch_to_validators( validators.as_slice(), &proposed_batch, diff --git a/crates/rpc/src/server/mod.rs b/crates/rpc/src/server/mod.rs index e9f0ce18ed..a6b037ddac 100644 --- a/crates/rpc/src/server/mod.rs +++ b/crates/rpc/src/server/mod.rs @@ -13,6 +13,7 @@ use miden_node_proto::clients::{ }; use miden_node_proto::server::{rpc_api, sequencer_api}; use miden_node_proto_build::rpc_api_descriptor; +use miden_node_store::allowlist::AccountAllowlist; use miden_node_store::state::{BlockWriter, ProofWriter, State}; use miden_node_tracing::grpc::grpc_trace_fn; use miden_node_tracing::info; @@ -73,6 +74,7 @@ pub enum RpcMode { Sequencer { block_producer: Box, validators: ValidatorClients, + allowlist: Arc, }, /// Full-node RPC. /// @@ -99,11 +101,12 @@ pub enum RpcMode { /// `Clone` because it is cloned once into `RpcService` and then read on every request; it never /// carries the full-node's store write capabilities ([`RpcMode`] does), since no handler needs /// them — those are consumed once by the sync loop at startup. -#[derive(Clone, Debug)] +#[derive(Clone)] pub(crate) enum RpcBackend { Sequencer { block_producer: Box, validators: ValidatorClients, + allowlist: Arc, }, FullNode { source_rpc: Box, @@ -119,10 +122,12 @@ impl RpcBackend { pub(crate) fn sequencer( block_producer: BlockProducerApi, validators: ValidatorClients, + allowlist: Arc, ) -> Self { Self::Sequencer { block_producer: Box::new(block_producer), validators, + allowlist, } } @@ -203,10 +208,15 @@ impl PreAuthSubmission { } impl RpcMode { - pub fn sequencer(block_producer: BlockProducerApi, validators: ValidatorClients) -> Self { + pub fn sequencer( + block_producer: BlockProducerApi, + validators: ValidatorClients, + allowlist: Arc, + ) -> Self { Self::Sequencer { block_producer: Box::new(block_producer), validators, + allowlist, } } @@ -237,9 +247,10 @@ impl RpcMode { /// [`RpcService`](api::RpcService). fn backend(&self) -> RpcBackend { match self { - Self::Sequencer { block_producer, validators } => RpcBackend::Sequencer { + Self::Sequencer { block_producer, validators, allowlist } => RpcBackend::Sequencer { block_producer: block_producer.clone(), validators: validators.clone(), + allowlist: Arc::clone(allowlist), }, Self::FullNode { source_rpc, pre_auth, .. } => RpcBackend::FullNode { source_rpc: source_rpc.clone(), @@ -353,6 +364,7 @@ impl Rpc { // CORS rejection). .layer( AcceptHeaderLayer::new(&rpc_version, genesis.commitment()) + .with_genesis_enforced_method("RegisterAccount") .with_genesis_enforced_method("SubmitProvenTx") .with_genesis_enforced_method("SubmitProvenTxBatch"), ) diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 7658f5b7d3..dac5795acd 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -26,6 +26,8 @@ use miden_node_proto::generated::rpc::api_server::Api; use miden_node_proto::generated::sequencer::api_server::Api as SequencerApi; use miden_node_proto::generated::{self as proto}; use miden_node_proto::server::{ntx_builder_api, rpc_api, sequencer_api, validator_api}; +use miden_node_store::DataDirectory; +use miden_node_store::allowlist::{AccountAllowlist, InvitationCode, InvitationEntry}; use miden_node_store::genesis::GenesisBlock; use miden_node_store::genesis::config::GenesisConfig; use miden_node_store::state::State; @@ -128,6 +130,14 @@ impl Drop for TestServerGuard { } impl TestStore { + fn bootstrap_allowlist(&self) -> Arc { + let path = DataDirectory::load(self.data_directory.clone()) + .unwrap() + .allowlist_database_path(); + AccountAllowlist::bootstrap(&path).unwrap(); + Arc::new(AccountAllowlist::load(path).unwrap()) + } + fn genesis_commitment(&self) -> Word { self.genesis_commitment } @@ -716,9 +726,17 @@ async fn rpc_server_forwards_valid_deferred_proofs_and_rejects_missing_witnesses BlockProducerApiConfig::default(), CancellationToken::new(), ); + let allowlist_path = + DataDirectory::load(data_directory.clone()).unwrap().allowlist_database_path(); + AccountAllowlist::bootstrap(&allowlist_path).unwrap(); + let allowlist = Arc::new(AccountAllowlist::load(allowlist_path).unwrap()); let service = RpcService::new( state, - RpcBackend::sequencer(block_producer, ValidatorClients::new(vec![validator]).unwrap()), + RpcBackend::sequencer( + block_producer, + ValidatorClients::new(vec![validator]).unwrap(), + allowlist, + ), None, NonZeroUsize::new(1_000_000).unwrap(), None, @@ -954,6 +972,7 @@ async fn start_source_rpc_with_genesis( }, None => TestStore::start().await, }; + let allowlist = store.bootstrap_allowlist(); let block_producer_dir = new_tempdir(); match genesis_block { Some((genesis_block, protocol_config)) => { @@ -988,6 +1007,7 @@ async fn start_source_rpc_with_genesis( RpcBackend::sequencer( block_producer, ValidatorClients::new(vec![validator]).unwrap(), + allowlist, ), Some(ntx_builder), NonZeroUsize::new(1_000_000).unwrap(), @@ -1541,6 +1561,7 @@ async fn connect_rpc(url: Url) -> RpcClient { async fn start_rpc() -> (RpcClient, std::net::SocketAddr, TestStore, TestServerGuard) { let grpc_options = GrpcOptions::test(); let store = TestStore::start().await; + let allowlist = store.bootstrap_allowlist(); let block_producer_dir = new_tempdir(); TestStore::bootstrap(&block_producer_dir); let (block_producer_state, ..) = State::for_tests(&block_producer_dir).await; @@ -1574,6 +1595,7 @@ async fn start_rpc() -> (RpcClient, std::net::SocketAddr, TestStore, TestServerG mode: RpcMode::sequencer( block_producer, ValidatorClients::new(vec![validator]).unwrap(), + allowlist, ), ntx_builder: None, grpc_options, @@ -1592,6 +1614,208 @@ async fn start_rpc() -> (RpcClient, std::net::SocketAddr, TestStore, TestServerG (rpc_client, rpc_addr, store, TestServerGuard(shutdown)) } +#[tokio::test] +async fn register_account_validates_input_and_preserves_registrations() { + let (mut rpc, addr, store, _server) = start_rpc().await; + let allowlist = AccountAllowlist::load( + DataDirectory::load(store.data_directory.clone()) + .unwrap() + .allowlist_database_path(), + ) + .unwrap(); + let [account, other] = [[0; 15], [1; 15]].map(|bytes| { + AccountId::dummy( + bytes, + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ) + }); + let request = proto::rpc::RegisterAccountRequest { + invitation_code: "abc".to_owned(), + account_id: Some(account.into()), + }; + assert_eq!( + rpc.register_account(request.clone()).await.unwrap_err().code(), + tonic::Code::InvalidArgument + ); + rpc = Builder::new(Url::parse(&format!("http://{addr}")).unwrap()) + .without_tls() + .with_timeout(REQUEST_TIMEOUT) + .without_metadata_version() + .with_metadata_genesis(store.genesis_commitment()) + .without_otel_context_injection() + .connect_lazy::(); + for invalid in [ + proto::rpc::RegisterAccountRequest { + invitation_code: String::new(), + ..request.clone() + }, + proto::rpc::RegisterAccountRequest { account_id: None, ..request.clone() }, + proto::rpc::RegisterAccountRequest { + account_id: Some(proto::account::AccountId { id: vec![0] }), + ..request.clone() + }, + ] { + assert_eq!( + rpc.register_account(invalid).await.unwrap_err().code(), + tonic::Code::InvalidArgument + ); + } + assert_eq!( + rpc.register_account(request.clone()).await.unwrap_err().code(), + tonic::Code::NotFound + ); + let invitation = InvitationCode::from_hex_digest( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ) + .unwrap(); + allowlist + .import_invitation(InvitationEntry { + invitation_code: invitation.clone(), + account_id: None, + }) + .await + .unwrap(); + let imported = allowlist.invitation_info(invitation.clone()).await.unwrap().unwrap(); + rpc.register_account(request.clone()).await.unwrap(); + rpc.register_account(request.clone()).await.unwrap(); + let conflict = proto::rpc::RegisterAccountRequest { + account_id: Some(other.into()), + ..request.clone() + }; + assert_eq!( + rpc.register_account(conflict).await.unwrap_err().code(), + tonic::Code::AlreadyExists + ); + let registered = allowlist.invitation_info(invitation).await.unwrap().unwrap(); + assert_eq!(registered.account_id, Some(account)); + assert_eq!(registered.allowlisted_at, imported.allowlisted_at); + assert!(!allowlist.contains_account(other).await.unwrap()); + + let unused = InvitationCode::new("unused").unwrap(); + allowlist + .import_invitation(InvitationEntry { + invitation_code: unused.clone(), + account_id: None, + }) + .await + .unwrap(); + assert_eq!( + rpc.register_account(proto::rpc::RegisterAccountRequest { + invitation_code: "unused".to_owned(), + ..request + }) + .await + .unwrap_err() + .code(), + tonic::Code::AlreadyExists + ); + assert_eq!(allowlist.invitation_info(unused).await.unwrap().unwrap().account_id, None); +} + +#[tokio::test] +async fn register_account_database_failures_include_the_cause() { + let (mut rpc, _addr, store, _server) = start_rpc().await; + let path = DataDirectory::load(store.data_directory.clone()) + .unwrap() + .allowlist_database_path(); + fs_err::remove_file(path).unwrap(); + + let account = AccountId::dummy( + [0; 15], + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ); + let mut request = Request::new(proto::rpc::RegisterAccountRequest { + invitation_code: "abc".to_owned(), + account_id: Some(account.into()), + }); + request.metadata_mut().insert( + ACCEPT.as_str(), + format!("application/vnd.miden; genesis={}", store.genesis_commitment()) + .parse() + .unwrap(), + ); + + let error = rpc.register_account(request).await.unwrap_err(); + assert_eq!(error.code(), tonic::Code::Internal); + assert!(error.message().contains("unable to open database file"), "{error}"); +} + +#[tokio::test] +async fn full_nodes_forward_account_registration_to_the_sequencer() { + let (source_rpc, _addr, source_store, _server) = start_rpc().await; + let allowlist = AccountAllowlist::load( + DataDirectory::load(source_store.data_directory.clone()) + .unwrap() + .allowlist_database_path(), + ) + .unwrap(); + let store = TestStore::start().await; + for (index, pre_auth) in [ + None, + Some(PreAuthSubmission::new(vec![dummy_client()], dummy_client()).unwrap()), + ] + .into_iter() + .enumerate() + { + let account = AccountId::dummy( + [u8::try_from(index).unwrap(); 15], + AccountIdVersion::Version1, + AccountType::Private, + AssetCallbackFlag::Disabled, + ); + let code = format!(" invitation-\u{e9}-{index}\n"); + let invitation = InvitationCode::new(&code).unwrap(); + let rpc = RpcService::new( + Arc::clone(&store.state), + RpcBackend::full_node(source_rpc.clone(), pre_auth), + None, + NonZeroUsize::new(1).unwrap(), + None, + ); + let registration = proto::rpc::RegisterAccountRequest { + invitation_code: code, + account_id: Some(account.into()), + }; + let request = || { + let mut request = Request::new(registration.clone()); + request.metadata_mut().insert( + ACCEPT.as_str(), + format!("application/vnd.miden; genesis={}", source_store.genesis_commitment()) + .parse() + .unwrap(), + ); + request + }; + assert_eq!( + rpc.register_account(request()).await.unwrap_err().code(), + tonic::Code::NotFound + ); + allowlist + .import_invitation(InvitationEntry { + invitation_code: invitation.clone(), + account_id: None, + }) + .await + .unwrap(); + rpc.register_account(request()).await.unwrap(); + rpc.register_account(request()).await.unwrap(); + assert_eq!( + allowlist.invitation_info(invitation).await.unwrap().unwrap().account_id, + Some(account) + ); + } + assert!( + !DataDirectory::load(store.data_directory.clone()) + .unwrap() + .allowlist_database_path() + .exists() + ); +} + #[tokio::test] async fn get_limits_endpoint() { // Start the RPC and store diff --git a/crates/store/src/allowlist/invitation.rs b/crates/store/src/allowlist/invitation.rs index 4d3d95ea1e..94f51ce4ac 100644 --- a/crates/store/src/allowlist/invitation.rs +++ b/crates/store/src/allowlist/invitation.rs @@ -6,18 +6,18 @@ use thiserror::Error; /// A nonempty invitation code represented by its SHA-256 digest. /// /// The digest permits code matching without storing a code that an attacker can redeem after a database leak. -/// Construction does not retain the original bytes. Debug output hides the digest. +/// Construction does not retain the original text. Debug output hides the digest. /// Callers must use random invitation codes with enough entropy to resist guessing. #[derive(Clone, PartialEq, Eq)] pub struct InvitationCode([u8; 32]); impl InvitationCode { - /// Computes a digest of the exact invitation code bytes without text normalization. - pub fn new(bytes: &[u8]) -> Result { - if bytes.is_empty() { + /// Computes a digest of the exact UTF-8 text without trimming or normalization. + pub fn new(code: &str) -> Result { + if code.is_empty() { return Err(InvalidInvitationCode); } - Ok(Self(Sha256::digest(bytes).into())) + Ok(Self(Sha256::digest(code.as_bytes()).into())) } /// Uses a caller-computed SHA-256 digest without hashing it again. The caller must compute the @@ -45,7 +45,7 @@ impl fmt::Debug for InvitationCode { } } -/// An invitation code must contain at least one byte. +/// An invitation code must not be empty. #[derive(Debug, Error, PartialEq, Eq)] #[error("invitation code must not be empty")] pub struct InvalidInvitationCode; diff --git a/crates/store/src/allowlist/tests.rs b/crates/store/src/allowlist/tests.rs index c91e3d2dc4..dc2fad93e2 100644 --- a/crates/store/src/allowlist/tests.rs +++ b/crates/store/src/allowlist/tests.rs @@ -46,7 +46,7 @@ fn account(index: usize) -> AccountId { } fn invitation(value: u8) -> InvitationCode { - InvitationCode::new(&[value; 16]).unwrap() + InvitationCode::new(&format!("invitation-{value}")).unwrap() } fn entry(value: u8, account_id: Option) -> InvitationEntry { @@ -281,9 +281,14 @@ async fn concurrent_invitations_cannot_register_the_same_account() { } #[test] -fn invitation_codes_reject_empty_input_and_hide_debug_values() { - assert!(InvitationCode::new(&[]).is_err()); - let invitation = InvitationCode::new(b"private invitation code").unwrap(); +fn invitation_codes_preserve_text_and_hide_debug_values() { + assert!(InvitationCode::new("").is_err()); + let code = InvitationCode::new("code").unwrap(); + for different in ["Code", " code ", "code\n"] { + assert_ne!(code, InvitationCode::new(different).unwrap()); + } + assert_ne!(InvitationCode::new("\u{e9}").unwrap(), InvitationCode::new("e\u{301}").unwrap()); + let invitation = InvitationCode::new("private invitation code").unwrap(); let debug = format!("{invitation:?}"); assert!(!debug.contains("private invitation code")); assert!(!debug.contains(&hex::encode(invitation.digest()))); diff --git a/docs/external/src/full-node/rpc.md b/docs/external/src/full-node/rpc.md index a3b6677c67..2b7989e7c9 100644 --- a/docs/external/src/full-node/rpc.md +++ b/docs/external/src/full-node/rpc.md @@ -22,6 +22,12 @@ must fetch the key first and a validator's answer does not change while it is ru Because sealing transaction inputs is mandatory, this endpoint is on the critical path for submission: a full node that cannot reach a validator or its upstream source can no longer serve submitting clients at all, not merely the key query. +## Account Registration + +Full nodes forward `RegisterAccount` requests to their configured upstream RPC source. The sequencer stores the +registration. Full nodes do not maintain a local allowlist. This also applies when pre-authenticated transaction +submission is configured. + ## Transaction Submission Full nodes do not sequence transactions. `SubmitProvenTx` and `SubmitProvenTxBatch` are forwarded to the configured diff --git a/docs/external/src/network-operator/sequencer.md b/docs/external/src/network-operator/sequencer.md index fabd9d0473..3869368342 100644 --- a/docs/external/src/network-operator/sequencer.md +++ b/docs/external/src/network-operator/sequencer.md @@ -56,8 +56,8 @@ access. Do not expose it through the public RPC ingress. | `PUT` | `/admin/allowlist/accounts/{account_id}` | None | `201` for a new registration, `204` if already registered. Adds the account without consuming an invitation. | | `GET` | `/admin/allowlist/accounts/{account_id}` | None | `account_id` and `allowlisted_at`, or `404` if not registered. | -Compute `invitation_digest` as SHA-256 of the exact invitation code bytes. For text codes, use UTF-8 without a trailing -newline or other normalization. Encode the digest as 64 hexadecimal characters without a `0x` prefix. The API stores +Compute `invitation_digest` as SHA-256 of the invitation code's exact UTF-8 representation. Do not trim the code, add a +newline, or normalize the text. Encode the digest as 64 hexadecimal characters without a `0x` prefix. The API stores this digest directly and does not hash it again. Generate nonempty random codes with enough entropy to resist guessing. Give the original code to the recipient. The administration API never receives or returns the original code. @@ -71,10 +71,14 @@ Each request changes one entry. To import multiple entries, send one request per without replacing registrations. An invitation `PUT` without an account preserves its current registration. The registry is stored in `miden-allowlist.sqlite3`, separately from the block database. Chain bootstrap does not create -it. Starting the sequencer administration API creates an empty registry if none exists, including when promoting an -existing full node. Existing registries are loaded without replacing their entries. Startup does not apply migrations. +it. Starting the sequencer creates an empty registry if none exists, including when promoting an existing full node. +Existing registries are loaded without replacing their entries. Startup does not apply migrations. `miden-node migrate --data-directory node-data` applies allowlist migrations only if the registry exists. +The public `RegisterAccount` RPC uses this registry even when the administration listener is disabled. It binds an +unused invitation to an account. See [Account Registration](../rpc/public-api.md#account-registration) for the request +and retry behavior. + Back up the registry separately. It is not replicated with blocks. Restore it before starting a replacement sequencer to preserve invitations and registrations. Without a restored registry, the replacement starts with an empty allowlist. diff --git a/docs/external/src/rpc/index.md b/docs/external/src/rpc/index.md index 0da68d6a8a..ca1b681929 100644 --- a/docs/external/src/rpc/index.md +++ b/docs/external/src/rpc/index.md @@ -51,6 +51,7 @@ The RPC server supports: | Status and limits | `Status`, `GetLimits` | | State queries | `GetAccount`, `GetBlockByNumber`, `GetBlockHeaderByNumber`, `GetNotesById`, `GetNoteScriptByRoot` | | Transaction submission | `GetTransactionEncryptionKey`, `SubmitProvenTx`, `SubmitProvenTxBatch` | +| Account registration | `RegisterAccount` | | State synchronization | `SyncTransactions`, `SyncNotes`, `SyncNullifiers`, `SyncAccountVault`, `SyncAccountStorageMaps`, `SyncChainMmr` | | Block streaming | `BlockSubscription`, `ProofSubscription` | | Network note debugging | `GetNetworkNoteStatus` | diff --git a/docs/external/src/rpc/public-api.md b/docs/external/src/rpc/public-api.md index 5f30b65479..36cce5c697 100644 --- a/docs/external/src/rpc/public-api.md +++ b/docs/external/src/rpc/public-api.md @@ -30,6 +30,19 @@ grpcurl rpc.testnet.miden.io:443 describe rpc.Api | `GetNotesById` | Returns committed notes matching the requested note IDs. | | `GetNoteScriptByRoot` | Returns a note script by script root when available. | +## Account Registration + +`RegisterAccount` binds an invitation code to an account ID. Send the original code string in `invitation_code` and the +target account in `account_id`. Codes are case-sensitive. Send the code exactly as received, without trimming or +normalization. Registration does not create an account on chain. + +Retrying the same code and account succeeds without changes. An unknown code returns `NOT_FOUND`. A code bound to +another account, or an account already registered with another entry, returns `ALREADY_EXISTS`. Invalid input returns +`INVALID_ARGUMENT`. Failed requests do not consume an invitation. + +Include the network's `genesis` parameter in the `Accept` header, as for transaction submission. Use TLS when sending +invitation codes over a network. Do not log invitation codes. Full nodes forward registration to the sequencer. + ## Transaction Submission | Method | Purpose | diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index d680be2c71..aa60e02c05 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -32,6 +32,13 @@ service Api { // Returns the latest details of the specified account. rpc GetAccount(AccountRequest) returns (AccountResponse) {} + // Registers an account with an invitation code on the sequencer. + // A retry with the same code and account succeeds without changes. + // Returns INVALID_ARGUMENT for invalid input, NOT_FOUND for an unknown code, and + // ALREADY_EXISTS if the code is registered to a different account or the account is already + // registered without this code. + rpc RegisterAccount(RegisterAccountRequest) returns (google.protobuf.Empty) {} + // Returns block data for the specified block number, optionally including the block proof. rpc GetBlockByNumber(BlockRequest) returns (MaybeBlock) {} @@ -125,6 +132,17 @@ service Api { rpc GetNetworkNoteStatus(note.NoteId) returns (GetNetworkNoteStatusResponse) {} } +// REGISTER ACCOUNT +// ================================================================================================ + +message RegisterAccountRequest { + // The original, nonempty invitation code. Codes are case-sensitive. + string invitation_code = 1; + + // The account to register. Registration does not create the account on chain. + account.AccountId account_id = 2; +} + // BLOCK SUBSCRIPTION // ================================================================================================