Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/store/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ rand = { workspace = true }
rand_chacha = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
thread-priority = { workspace = true }
tokio = { features = ["fs", "rt-multi-thread"], workspace = true }
Expand Down
4 changes: 4 additions & 0 deletions crates/store/build.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
miden_node_db::migration::Migrator::generate("src/db/migrations", "db_migrator.rs")?;
miden_node_db::migration::Migrator::generate(
"src/allowlist/migrations",
"allowlist_migrator.rs",
)?;

// If we do one re-write, the default rules are disabled,
// hence we need to trigger explicitly on `Cargo.toml`.
Expand Down
37 changes: 37 additions & 0 deletions crates/store/src/allowlist/invitation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use std::fmt;

use sha2::{Digest, Sha256};
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.
/// 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<Self, InvalidInvitationCode> {
if bytes.is_empty() {
return Err(InvalidInvitationCode);
}
Ok(Self(Sha256::digest(bytes).into()))
}

pub(crate) fn digest(&self) -> &[u8] {
&self.0
}
}

impl fmt::Debug for InvitationCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("InvitationCode([REDACTED])")
}
}

/// An invitation code must contain at least one byte.
#[derive(Debug, Error, PartialEq, Eq)]
#[error("invitation code must not be empty")]
pub struct InvalidInvitationCode;
1 change: 1 addition & 0 deletions crates/store/src/allowlist/migrations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include!(concat!(env!("OUT_DIR"), "/allowlist_migrator.rs"));
11 changes: 11 additions & 0 deletions crates/store/src/allowlist/migrations/001_initial.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE account_allowlist (
id INTEGER PRIMARY KEY,
account_id BLOB,
invitation_digest BLOB,
created_at BIGINT NOT NULL,
CHECK (account_id IS NOT NULL OR invitation_digest IS NOT NULL),
CHECK (length(invitation_digest) = 32)
);

CREATE UNIQUE INDEX idx_account_allowlist_account_id ON account_allowlist(account_id);
CREATE UNIQUE INDEX idx_account_allowlist_invitation_digest ON account_allowlist(invitation_digest);
243 changes: 243 additions & 0 deletions crates/store/src/allowlist/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
//! Stores account registrations and invitation codes for the sequencer.
//!
//! The registry contains unused invitation codes, accounts registered with an invitation code, and accounts added directly.
//! Registry membership does not depend on account deployment or transaction admission policy.

use std::path::Path;

use miden_node_db::sqlite::{DbReader, DbWriter, WriteTx};
use miden_protocol::account::AccountId;
use thiserror::Error;

use crate::DatabaseError;

mod invitation;
mod migrations;
mod queries;

pub use invitation::{InvalidInvitationCode, InvitationCode};

#[cfg(test)]
mod tests;

/// An invitation code to import, with an optional account registration.
#[derive(Clone, Debug)]
pub struct InvitationEntry {
pub invitation_code: InvitationCode,
pub account_id: Option<AccountId>,
}

/// The registration state of an invitation code.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InvitationStatus {
Unknown,
Unused,
Registered(AccountId),
}

/// The result of a successful registration request.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RegistrationOutcome {
Registered,
/// The same invitation code was already registered to the same account.
AlreadyRegistered,
}

/// A registry operation failed.
#[derive(Debug, Error)]
pub enum AllowlistError {
#[error("invitation code does not exist")]
InvitationNotFound,
#[error("invitation code is already registered to another account")]
InvitationAlreadyUsed,
#[error("account {0} is already registered")]
AccountAlreadyRegistered(AccountId),
#[error("account registry database operation failed")]
Database(#[source] DatabaseError),
}

/// Read-only access to the account registry.
#[derive(Clone)]
pub struct AccountAllowlistReader {
db: DbReader,
}

impl AccountAllowlistReader {
/// Returns whether the registry contains the account.
pub async fn contains_account(&self, account_id: AccountId) -> Result<bool, DatabaseError> {
self.db
.read("allowlist.contains_account", move |tx| {
queries::contains_account(tx, account_id)
})
.await
.map_err(DatabaseError::DatabaseError)
}

/// Returns the registration state of the invitation code.
pub async fn invitation_status(
&self,
invitation_code: InvitationCode,
) -> Result<InvitationStatus, DatabaseError> {
self.db
.read("allowlist.invitation_status", move |tx| {
queries::invitation_status(tx, &invitation_code)
})
.await
.map_err(DatabaseError::DatabaseError)
}
}

/// Persistent account registry in a separate SQLite database.
///
/// The registry has separate reader and writer pools. Its writes do not wait for block database writes.
/// Each entry records its creation time in UTC Unix seconds. Registration and retries preserve this time.
/// Write transactions acquire the write lock before they read registrations.
/// Each write operation commits all its changes together. Failed operations leave no changes.
pub struct AccountAllowlist {
writer: DbWriter,
reader: AccountAllowlistReader,
}

impl std::ops::Deref for AccountAllowlist {
type Target = AccountAllowlistReader;

fn deref(&self) -> &Self::Target {
&self.reader
}
}

impl AccountAllowlist {
/// Creates the registry database and applies all migrations.
///
/// The database file must not exist.
pub fn bootstrap(database_filepath: impl AsRef<Path>) -> Result<(), DatabaseError> {
let migrator = migrations::migrator()
.map_err(miden_node_db::DatabaseError::migration)
.map_err(DatabaseError::DatabaseError)?;
migrator
.bootstrap(database_filepath)
.map_err(miden_node_db::DatabaseError::migration)
.map_err(DatabaseError::DatabaseError)
}

/// Opens the registry database after verifying its schema.
///
/// The database must exist and have the latest schema. This method does not apply migrations.
pub fn load(database_filepath: impl AsRef<Path>) -> Result<Self, DatabaseError> {
let database_filepath = database_filepath.as_ref();
let migrator = migrations::migrator()
.map_err(miden_node_db::DatabaseError::migration)
.map_err(DatabaseError::DatabaseError)?;
migrator
.verify_latest_schema(database_filepath)
.map_err(miden_node_db::DatabaseError::migration)
.map_err(DatabaseError::DatabaseError)?;
let (writer, reader) =
miden_node_db::sqlite::open(database_filepath).map_err(DatabaseError::DatabaseError)?;
Ok(Self {
writer,
reader: AccountAllowlistReader { db: reader },
})
}

/// Applies pending migrations to an existing registry database.
pub fn migrate(database_filepath: impl AsRef<Path>) -> Result<(), DatabaseError> {
let migrator = migrations::migrator()
.map_err(miden_node_db::DatabaseError::migration)
.map_err(DatabaseError::DatabaseError)?;
migrator
.migrate(database_filepath)
.map_err(miden_node_db::DatabaseError::migration)
.map_err(DatabaseError::DatabaseError)
}

/// Returns a read-only handle that shares the reader pool.
pub fn reader(&self) -> AccountAllowlistReader {
self.reader.clone()
}

/// Imports invitation codes and their optional account registrations in one transaction.
///
/// An entry without an account preserves any existing registration for its invitation code.
/// An entry with an account can register an unused invitation code. An identical registration has no effect.
/// A conflicting registration rejects the whole import.
pub async fn import_invitations(
&self,
entries: Vec<InvitationEntry>,
) -> Result<(), AllowlistError> {
self.transact("allowlist.import_invitations", move |tx| {
for entry in entries {
queries::import_invitation(tx, &entry)?;
}
Ok(())
})
.await
}

/// Adds accounts without invitation codes in one transaction and returns the number of new registrations.
///
/// Existing accounts keep their invitation code registrations, if any.
pub async fn add_accounts(&self, accounts: Vec<AccountId>) -> Result<usize, DatabaseError> {
self.writer
.write("allowlist.add_accounts", move |tx| {
let mut inserted = 0;
for account_id in accounts {
inserted += queries::add_account(tx, account_id)?;
}
Ok::<_, miden_node_db::DatabaseError>(inserted)
})
.await
.map_err(DatabaseError::DatabaseError)
}

/// Registers an unused invitation code to an account in one transaction.
///
/// A retry with the same invitation code and account succeeds without changes.
/// An account already registered by another method cannot consume an unused invitation code.
pub async fn register_account(
&self,
invitation_code: InvitationCode,
account_id: AccountId,
) -> Result<RegistrationOutcome, AllowlistError> {
self.transact("allowlist.register_account", move |tx| {
queries::register_account(tx, &invitation_code, account_id)
})
.await
}

async fn transact<T: Send + 'static>(
&self,
name: &'static str,
query: impl FnOnce(&WriteTx<'_>) -> Result<T, AllowlistError> + Send + 'static,
) -> Result<T, AllowlistError> {
let tx = self
.writer
.begin_write()
.await
.map_err(DatabaseError::DatabaseError)
.map_err(AllowlistError::Database)?;
let result = tx
.run(name, move |tx| Ok::<_, miden_node_db::DatabaseError>(query(tx)))
.await
.map_err(DatabaseError::DatabaseError)
.map_err(AllowlistError::Database)
.and_then(std::convert::identity);

match result {
Ok(value) => {
tx.commit()
.await
.map_err(DatabaseError::DatabaseError)
.map_err(AllowlistError::Database)?;
Ok(value)
},
Err(error) => {
tx.rollback()
.await
.map_err(DatabaseError::DatabaseError)
.map_err(AllowlistError::Database)?;
Err(error)
},
}
}
}
Loading
Loading