independent sql zone repo

This commit is contained in:
kashepavadan 2026-04-15 10:57:04 -04:00
parent 1c923e010b
commit 3e856a3a72
28 changed files with 12409 additions and 12 deletions

27
.env.example-local Normal file
View File

@ -0,0 +1,27 @@
# SQlite Zone Demo - Local Development Environment
# Usage: ./run-local.sh --env-file PATH/TO/FILE/.env-local
# Sequencer node endpoint (for submitting transactions)
SEQUENCER_NODE_ENDPOINT=https://devnet.blockchain.logos.co/node/0/
SEQUENCER_NODE_AUTH_USERNAME=
SEQUENCER_NODE_AUTH_PASSWORD=
#Path to Sequencer's DB
SEQUENCER_DB_PATH=./database.db
# Path to signing key file
SEQUENCER_SIGNING_KEY_PATH=
# Path to queue file for pending SQL statements
QUEUE_FILE=./query.txt
# Path to checkpoint file for recovery
CHECKPOINT_PATH=./sequencer.checkpoint
# Indexer node endpoint (for reading blocks - can be different node)
INDEXER_NODE_ENDPOINT=https://devnet.blockchain.logos.co/node/1/
INDEXER_NODE_AUTH_USERNAME=
INDEXER_NODE_AUTH_PASSWORD=
# Path to Indexer's local db
INDEXER_DB_PATH=./indexer.db

8527
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

254
Cargo.toml Normal file
View File

@ -0,0 +1,254 @@
[workspace.package]
categories = ["cryptography", "cryptography::cryptocurrencies", "security"]
description = "Logos blockchain workspace crates. For more information please visit https://logos.co/."
edition = "2024"
keywords = ["blockchain", "privacy"]
license = "MIT or Apache-2.0"
readme = "README.md"
repository = "https://github.com/logos-blockchain/logos-blockchain"
version = "0.2.1"
[workspace]
members = ["common", "sequencer", "indexer"]
resolver = "2"
[workspace.dependencies]
# Demo crates
demo-sqlite-common = { path = "./common" }
# logos-blockchain internal crates (paths adjusted for submodule location)
cfgsync-adapter = { default-features = false, git = "https://github.com/logos-blockchain/logos-blockchain-testing.git", rev = "f731791" }
cfgsync-artifacts = { default-features = false, git = "https://github.com/logos-blockchain/logos-blockchain-testing.git", rev = "f731791" }
cfgsync-core = { default-features = false, git = "https://github.com/logos-blockchain/logos-blockchain-testing.git", rev = "f731791" }
common-http-client = { default-features = false, package = "logos-blockchain-common-http-client", path = "./logos-blockchain/nodes/node/http-client" }
lb-api-service = { default-features = false, package = "logos-blockchain-api-service", path = "./logos-blockchain/services/api" }
lb-blend = { default-features = false, package = "logos-blockchain-blend", path = "./logos-blockchain/blend/core" }
lb-blend-crypto = { default-features = false, package = "logos-blockchain-blend-crypto", path = "./logos-blockchain/blend/crypto" }
lb-blend-message = { default-features = false, package = "logos-blockchain-blend-message", path = "./logos-blockchain/blend/message" }
lb-blend-network = { default-features = false, package = "logos-blockchain-blend-network", path = "./logos-blockchain/blend/network" }
lb-blend-proofs = { default-features = false, package = "logos-blockchain-blend-proofs", path = "./logos-blockchain/blend/proofs" }
lb-blend-scheduling = { default-features = false, package = "logos-blockchain-blend-scheduling", path = "./logos-blockchain/blend/scheduling" }
lb-blend-service = { default-features = false, package = "logos-blockchain-blend-service", path = "./logos-blockchain/services/blend" }
lb-cfgsync = { default-features = false, package = "logos-blockchain-cfgsync", path = "./logos-blockchain/testnet/cfgsync" }
lb-chain-broadcast-service = { default-features = false, package = "logos-blockchain-chain-broadcast-service", path = "./logos-blockchain/services/chain/broadcast-service" }
lb-chain-leader-service = { default-features = false, package = "logos-blockchain-chain-leader-service", path = "./logos-blockchain/services/chain/chain-leader" }
lb-chain-network-service = { default-features = false, package = "logos-blockchain-chain-network-service", path = "./logos-blockchain/services/chain/chain-network" }
lb-chain-service = { default-features = false, package = "logos-blockchain-chain-service", path = "./logos-blockchain/services/chain/chain-service" }
lb-chain-service-common = { default-features = false, package = "logos-blockchain-chain-service-common", path = "./logos-blockchain/services/chain/chain-common" }
lb-circuits-prover = { default-features = false, package = "logos-blockchain-circuits-prover", path = "./logos-blockchain/zk/circuits/prover" }
lb-circuits-utils = { default-features = false, package = "logos-blockchain-circuits-utils", path = "./logos-blockchain/zk/circuits/utils" }
lb-circuits-verifier = { default-features = false, package = "logos-blockchain-circuits-verifier", path = "./logos-blockchain/zk/circuits/verifier" }
lb-common-http-client = { default-features = false, package = "logos-blockchain-common-http-client", path = "./logos-blockchain/nodes/node/http-client" }
lb-core = { default-features = false, package = "logos-blockchain-core", path = "./logos-blockchain/core" }
lb-cryptarchia-engine = { default-features = false, package = "logos-blockchain-cryptarchia-engine", path = "./logos-blockchain/consensus/cryptarchia-engine" }
lb-cryptarchia-sync = { default-features = false, package = "logos-blockchain-cryptarchia-sync", path = "./logos-blockchain/consensus/cryptarchia-sync" }
lb-demo-archiver = { default-features = false, package = "logos-blockchain-demo-archiver", path = "./logos-blockchain/testnet/l2-sequencer-archival-demo/archiver" }
lb-demo-sequencer = { default-features = false, package = "logos-blockchain-demo-sequencer", path = "./logos-blockchain/testnet/l2-sequencer-archival-demo/sequencer" }
lb-groth16 = { default-features = false, package = "logos-blockchain-groth16", path = "./logos-blockchain/zk/groth16" }
lb-http-api-common = { default-features = false, package = "logos-blockchain-http-api-common", path = "./logos-blockchain/nodes/api-common" }
lb-key-management-system-keys = { default-features = false, package = "logos-blockchain-key-management-system-keys", path = "./logos-blockchain/kms/keys" }
lb-key-management-system-macros = { default-features = false, package = "logos-blockchain-key-management-system-macros", path = "./logos-blockchain/kms/macros" }
lb-key-management-system-operators = { default-features = false, package = "logos-blockchain-key-management-system-operators", path = "./logos-blockchain/kms/operators" }
lb-key-management-system-service = { default-features = false, package = "logos-blockchain-key-management-system-service", path = "./logos-blockchain/services/key-management-system" }
lb-ledger = { default-features = false, package = "logos-blockchain-ledger", path = "./logos-blockchain/ledger" }
lb-libp2p = { default-features = false, package = "logos-blockchain-libp2p", path = "./logos-blockchain/libp2p" }
lb-mmr = { default-features = false, package = "logos-blockchain-mmr", path = "./logos-blockchain/mmr" }
lb-network-service = { default-features = false, package = "logos-blockchain-network-service", path = "./logos-blockchain/services/network" }
lb-node = { default-features = false, package = "logos-blockchain-node", path = "./logos-blockchain/nodes/node/binary" }
lb-poc = { default-features = false, package = "logos-blockchain-poc", path = "./logos-blockchain/zk/proofs/poc" }
lb-pol = { default-features = false, package = "logos-blockchain-pol", path = "./logos-blockchain/zk/proofs/pol" }
lb-poq = { default-features = false, package = "logos-blockchain-poq", path = "./logos-blockchain/zk/proofs/poq" }
lb-poseidon2 = { default-features = false, package = "logos-blockchain-poseidon2", path = "./logos-blockchain/zk/poseidon2" }
lb-sdp-service = { default-features = false, package = "logos-blockchain-sdp-service", path = "./logos-blockchain/services/sdp" }
lb-services-utils = { default-features = false, package = "logos-blockchain-services-utils", path = "./logos-blockchain/services/utils" }
lb-storage-service = { default-features = false, package = "logos-blockchain-storage-service", path = "./logos-blockchain/services/storage" }
lb-system-sig-service = { default-features = false, package = "logos-blockchain-system-sig-service", path = "./logos-blockchain/services/system-sig" }
lb-testing-framework = { default-features = false, package = "testing_framework", path = "./logos-blockchain/tests/testing_framework" }
lb-tests = { default-features = false, package = "logos-blockchain-tests", path = "./logos-blockchain/tests" }
lb-time-service = { default-features = false, package = "logos-blockchain-time-service", path = "./logos-blockchain/services/time" }
lb-tracing = { default-features = false, package = "logos-blockchain-tracing", path = "./logos-blockchain/tracing" }
lb-tracing-service = { default-features = false, package = "logos-blockchain-tracing-service", path = "./logos-blockchain/services/tracing" }
lb-tx-service = { default-features = false, package = "logos-blockchain-tx-service", path = "./logos-blockchain/services/tx-service" }
lb-utils = { default-features = false, package = "logos-blockchain-utils", path = "./logos-blockchain/utils" }
lb-utxotree = { default-features = false, package = "logos-blockchain-utxotree", path = "./logos-blockchain/utxotree" }
lb-wallet = { default-features = false, package = "logos-blockchain-wallet", path = "./logos-blockchain/wallet" }
lb-wallet-http-client = { default-features = false, package = "logos-blockchain-wallet-http-client", path = "./logos-blockchain/wallet-http-client" }
lb-wallet-service = { default-features = false, package = "logos-blockchain-wallet-service", path = "./logos-blockchain/services/wallet" }
lb-witness-generator = { default-features = false, package = "logos-blockchain-witness-generator", path = "./logos-blockchain/zk/circuits/witness-generator" }
lb-zksign = { default-features = false, package = "logos-blockchain-zksign", path = "./logos-blockchain/zk/proofs/zksign" }
lb-zone-sdk = { default-features = false, package = "logos-blockchain-zone-sdk", path = "./logos-blockchain/zone-sdk" }
lb_network = { default-features = false, package = "logos-blockchain-network-service", path = "./logos-blockchain/services/network" }
testing-framework-core = { default-features = false, git = "https://github.com/logos-blockchain/logos-blockchain-testing.git", rev = "f731791" }
testing-framework-runner-compose = { default-features = false, git = "https://github.com/logos-blockchain/logos-blockchain-testing.git", rev = "f731791" }
testing-framework-runner-k8s = { default-features = false, git = "https://github.com/logos-blockchain/logos-blockchain-testing.git", rev = "f731791" }
testing-framework-runner-local = { default-features = false, git = "https://github.com/logos-blockchain/logos-blockchain-testing.git", rev = "f731791" }
# External (same versions as logos-blockchain workspace)
async-trait = { default-features = false, version = "0.1" }
blake2 = { default-features = false, version = "0.10" }
bytes = { default-features = false, version = "1.3" }
cached = { default-features = false, version = "0.55.1" }
cfg-if = { default-features = false, version = "1.0.4" }
divan = { default-features = false, version = "0.1" }
ed25519-dalek = { default-features = false, version = "2" }
fork_stream = { default-features = false, version = "0.1.0" }
futures = { default-features = false, version = "0.3.32" }
futures-util = { default-features = false, version = "0.3.32" }
hex = { default-features = false, version = "0.4" }
kube = { default-features = false, features = ["client", "rustls-tls"], version = "0.87" }
libp2p = { default-features = false, version = "0.55" }
libp2p-stream = { default-features = false, version = "0.3.0-alpha" }
log = { default-features = false, version = "0.4" }
overwatch = { default-features = false, git = "https://github.com/logos-co/Overwatch", rev = "448c192" }
overwatch-derive = { default-features = false, git = "https://github.com/logos-co/Overwatch", rev = "448c192" }
rand = { default-features = false, version = "0.8" }
reqwest = { default-features = false, version = "0.12" }
serde = { default-features = false, version = "1.0" }
serde-big-array = { default-features = false, version = "0.5" }
serde_ignored = { default-features = false, version = "0.1" }
serde_json = { default-features = false, version = "1.0" }
serde_with = { default-features = false, version = "3.14.0" }
serde_yaml = { default-features = false, version = "0.9.33" }
subtle = { default-features = false, version = "2.6.1" }
tempfile = { default-features = false, version = "3" }
thiserror = { default-features = false, version = "2.0" }
time = { default-features = false, version = "0.3" }
tokio = { default-features = false, version = "1" }
tokio-stream = { default-features = false, version = "0.1.18" }
tracing = { default-features = false, version = "0.1" }
utoipa = { default-features = false, version = "4.0" }
utoipa-swagger-ui = { default-features = false, version = "7.0" }
uuid = { default-features = false, features = ["v4"], version = "1" }
x25519-dalek = { default-features = false, version = "2" }
zeroize = { default-features = false, version = "1" }
[workspace.lints.clippy]
cargo = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
restriction = { level = "warn", priority = -1 }
multiple_crate_versions = { level = "allow" }
similar_names = { level = "allow" }
absolute_paths = { level = "allow" }
alloc_instead_of_core = { level = "allow" }
arbitrary_source_item_ordering = { level = "allow" }
big_endian_bytes = { level = "allow" }
blanket_clippy_restriction_lints = { level = "allow" }
decimal_literal_representation = { level = "allow" }
default_numeric_fallback = { level = "allow" }
deref_by_slicing = { level = "allow" }
else_if_without_else = { level = "allow" }
exhaustive_enums = { level = "allow" }
exhaustive_structs = { level = "allow" }
exit = { level = "allow" }
expect_used = { level = "allow" }
field_scoped_visibility_modifiers = { level = "allow" }
float_arithmetic = { level = "allow" }
get_unwrap = { level = "allow" }
host_endian_bytes = { level = "allow" }
implicit_return = { level = "allow" }
integer_division_remainder_used = { level = "allow" }
iter_over_hash_type = { level = "allow" }
let_underscore_must_use = { level = "allow" }
let_underscore_untyped = { level = "allow" }
little_endian_bytes = { level = "allow" }
map_err_ignore = { level = "allow" }
min_ident_chars = { level = "allow" }
missing_asserts_for_indexing = { level = "allow" }
missing_docs_in_private_items = { level = "allow" }
missing_inline_in_public_items = { level = "allow" }
missing_trait_methods = { level = "allow" }
mixed_read_write_in_expression = { level = "allow" }
mod_module_files = { level = "allow" }
module_name_repetitions = { level = "allow" }
modulo_arithmetic = { level = "allow" }
panic = { level = "allow" }
panic_in_result_fn = { level = "allow" }
partial_pub_fields = { level = "allow" }
print_stderr = { level = "allow" }
print_stdout = { level = "allow" }
pub_use = { level = "allow" }
pub_with_shorthand = { level = "allow" }
question_mark_used = { level = "allow" }
self_named_module_files = { level = "allow" }
semicolon_inside_block = { level = "allow" }
single_call_fn = { level = "allow" }
single_char_lifetime_names = { level = "allow" }
std_instead_of_alloc = { level = "allow" }
std_instead_of_core = { level = "allow" }
struct_field_names = { level = "allow" }
unseparated_literal_suffix = { level = "allow" }
use_debug = { level = "allow" }
wildcard_enum_match_arm = { level = "allow" }
arithmetic_side_effects = { level = "allow" }
as_conversions = { level = "allow" }
as_pointer_underscore = { level = "allow" }
as_underscore = { level = "allow" }
assertions_on_result_states = { level = "allow" }
cast_possible_truncation = { level = "allow" }
cast_possible_wrap = { level = "allow" }
cast_precision_loss = { level = "allow" }
cast_sign_loss = { level = "allow" }
doc_paragraphs_missing_punctuation = { level = "allow" }
error_impl_error = { level = "allow" }
impl_trait_in_params = { level = "allow" }
indexing_slicing = { level = "allow" }
infinite_loop = { level = "allow" }
integer_division = { level = "allow" }
large_stack_frames = { level = "allow" }
missing_assert_message = { level = "allow" }
missing_errors_doc = { level = "allow" }
missing_panics_doc = { level = "allow" }
pattern_type_mismatch = { level = "allow" }
redundant_test_prefix = { level = "allow" }
ref_patterns = { level = "allow" }
renamed_function_params = { level = "allow" }
same_name_method = { level = "allow" }
shadow_reuse = { level = "allow" }
shadow_same = { level = "allow" }
shadow_unrelated = { level = "allow" }
tests_outside_test_module = { level = "allow" }
todo = { level = "allow" }
unchecked_time_subtraction = { level = "allow" }
unimplemented = { level = "allow" }
unreachable = { level = "allow" }
unwrap_in_result = { level = "allow" }
unwrap_used = { level = "allow" }
[workspace.lints.rust]
unused_crate_dependencies = { level = "allow" }
unused_results = { level = "allow" }
ambiguous_negative_literals = { level = "warn" }
closure_returning_async_block = { level = "warn" }
deref_into_dyn_supertrait = { level = "warn" }
impl_trait_redundant_captures = { level = "warn" }
let_underscore_drop = { level = "warn" }
macro_use_extern_crate = { level = "warn" }
missing_unsafe_on_extern = { level = "warn" }
redundant_imports = { level = "warn" }
redundant_lifetimes = { level = "warn" }
single_use_lifetimes = { level = "warn" }
tail_expr_drop_order = { level = "warn" }
trivial_numeric_casts = { level = "warn" }
unit_bindings = { level = "warn" }
unsafe_attr_outside_unsafe = { level = "warn" }
unsafe_op_in_unsafe_fn = { level = "warn" }
unstable_features = { level = "warn" }
unused_extern_crates = { level = "warn" }
unused_import_braces = { level = "warn" }
unused_lifetimes = { level = "warn" }
unused_macro_rules = { level = "warn" }
unused_qualifications = { level = "warn" }
absolute_paths_not_starting_with_crate = { level = "allow" }
elided_lifetimes_in_paths = { level = "allow" }
ffi_unwind_calls = { level = "allow" }
impl_trait_overcaptures = { level = "allow" }
linker_messages = { level = "allow" }
missing_copy_implementations = { level = "allow" }
missing_debug_implementations = { level = "allow" }
missing_docs = { level = "allow" }
trivial_casts = { level = "allow" }
unreachable_pub = { level = "allow" }
unsafe_code = { level = "allow" }
variant_size_differences = { level = "allow" }

View File

@ -1,7 +1,6 @@
# Logos SQLite Zone Sequencer and Indexer Demo - TUTORIAL SKELETON # Logos SQLite Zone Sequencer and Indexer Demo
This directory contains a skeleton implementation of a Sovereign Zone solution using the Logos Blockchain as a simple database server. It is meant to be used in conjunction with the Zone SDK tutorial in the logos-docs repository.
This directory contains a reference implementation of a Sovereign Zone solution using the Logos Blockchain as a simple database server.
## System Architecture ## System Architecture
In this demo, the sequencer acts as the primary maintainer of a [Steelsafe password manager](#steelsafe-a-pure-rust-safe-tui-password-manager), with DB updates published to the Logos Blockchain. Other parties, known as indexers, can follow these updates to reconstruct the same database locally as a read-only password manager. In this demo, the sequencer acts as the primary maintainer of a [Steelsafe password manager](#steelsafe-a-pure-rust-safe-tui-password-manager), with DB updates published to the Logos Blockchain. Other parties, known as indexers, can follow these updates to reconstruct the same database locally as a read-only password manager.
@ -31,7 +30,22 @@ Each component is a standalone service that can be run independently or via Dock
* **Rust**: For building the Sequencer and Indexer binaries, if running the helper script. * **Rust**: For building the Sequencer and Indexer binaries, if running the helper script.
* **Logos Node**: To read from and write to the Logos Blockchain. * **Logos Node**: To read from and write to the Logos Blockchain.
### 1. Running the Sequencer ### 1. Configuration
If you want the program to get information from the environment, copy the example environment file and fill in your information before exporting the variables.
```bash
cp testnet/sqlite-zone-demo/.env.example-local testnet/sqlite-zone-demo/.env-local
set -a
source testnet/sqlite-zone-demo/.env-local
set +a
```
You can also provide these fields via command line arguments (see below).
In either case, you will need access to a running **Logos Node**, as well as any credentials needed to interact with the node. If you are running a node locally, ensure the `SEQUENCER_NODE_ENDPOINT` and `INDEXER_NODE_ENDPOINT` in your `.env-local` both point to your local node.
### 2. Running the Sequencer
You can run the following file to execute the sequencer directly: `run-local.sh`. You can run the following file to execute the sequencer directly: `run-local.sh`.
@ -62,9 +76,9 @@ The information in the environment variables can also be provided to the script
| `--checkpoint-path ./sequencer.checkpoint` | Path to the checkpoint file for crash recovery. | | `--checkpoint-path ./sequencer.checkpoint` | Path to the checkpoint file for crash recovery. |
| `--channel-path ./channel.txt` | Path to the channel ID file (for the indexer to read). | | `--channel-path ./channel.txt` | Path to the channel ID file (for the indexer to read). |
Running this script should allow you to enter SQL queries into the command line. Running this script should allow you to interact with the read & write password manager.
### 2. Running the Indexer ### 3. Running the Indexer
You can run the following file to execute the sequencer directly: `run-local.sh`. You can run the following file to execute the sequencer directly: `run-local.sh`.

35
common/Cargo.toml Normal file
View File

@ -0,0 +1,35 @@
[package]
categories = { workspace = true }
description = "Shared utilities for SQLite zone demo crates"
edition = { workspace = true }
keywords = { workspace = true }
license = { workspace = true }
name = "demo-sqlite-common"
readme = { workspace = true }
repository = { workspace = true }
version = { workspace = true }
[lints]
workspace = true
[dependencies]
arboard = "3.4.1"
argon2 = { features = ["std", "zeroize"], version = "0.5" }
block-padding = { features = ["std"], version = "0.3" }
chacha20poly1305 = { features = ["std"], version = "0.10" }
chrono = { features = ["serde"], version = "0.4" }
crypto-common = { features = ["std"], version = "0.1.6" }
directories = "6.0"
logos-blockchain-zone-sdk = { path = "../logos-blockchain/zone-sdk" }
nanosql = { features = ["chrono"], version = "0.10.0" }
rand = { version = "0.9" }
ratatui = { features = ["serde"], version = "0.29" }
serde = { features = ["derive"], workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing-subscriber = { features = ["env-filter"], version = "0.3" }
zeroize = { features = ["alloc"], workspace = true }
[dev-dependencies]
rand = { version = "0.9" }
zxcvbn = "3"

134
common/src/config.rs Normal file
View File

@ -0,0 +1,134 @@
//! Configures the environment of the application: color themes, database path,
//! etc.
use std::{fs::File, io::ErrorKind, path::Path};
use directories::{ProjectDirs, UserDirs};
use ratatui::style::{Color, Style};
use serde::Deserialize;
use crate::error::{Error, Result, ResultExt as _};
/// Configures the environment of the application.
#[derive(Clone, Default, Debug, Deserialize)]
pub struct Config {
/// Colors and other TUI style settings.
#[serde(default)]
pub theme: Theme,
}
impl Config {
/// Reads the config from the `.steelsaferc` file if it exists.
/// Otherwise, returns the default configuration.
///
/// The config is first searched at the [permanent config directory][1],
/// and then under `$HOME`
///
/// If the file exists but it contains syntax errors, an error is returned.
pub fn from_rc_file() -> Result<Self> {
// First, search in the config directory
if let Ok(project_dirs) = Self::project_dirs() {
let config_path = project_dirs.config_dir().join(".steelsaferc");
if let Some(config_file) = Self::open_file_if_exists(&config_path)? {
// do NOT silently ignore JSON syntax/semantic errors!
return serde_json::from_reader(config_file).context("Invalid .steelsaferc");
}
}
// If not found, search in $HOME
if let Some(user_dirs) = UserDirs::new() {
let config_path = user_dirs.home_dir().join(".steelsaferc");
if let Some(config_file) = Self::open_file_if_exists(&config_path)? {
return serde_json::from_reader(config_file).context("Invalid .steelsaferc");
}
}
// not found anywhere, return the built-in default config
Ok(Self::default())
}
fn project_dirs() -> Result<ProjectDirs> {
ProjectDirs::from("org", "h2co3", "steelsafe").ok_or(Error::MissingDatabaseDir)
}
fn open_file_if_exists(path: &Path) -> Result<Option<File>> {
match File::open(path) {
Ok(file) => Ok(Some(file)),
Err(error) => {
if [ErrorKind::NotFound, ErrorKind::PermissionDenied].contains(&error.kind()) {
Ok(None)
} else {
Err(Error::context(error, "Found .steelsaferc but cannot open"))
}
}
}
}
}
/// A pair of background and foreground colors.
#[derive(Clone, Default, Debug, Deserialize)]
pub struct ColorPair {
/// The background color.
#[serde(default)]
pub bg: Option<Color>,
/// The foreground color.
#[serde(default)]
pub fg: Option<Color>,
}
/// Colors and other TUI style settings.
#[derive(Clone, Default, Debug, Deserialize)]
pub struct Theme {
/// The default colors, for general content/text.
#[serde(default)]
pub default: ColorPair,
/// Colors for important content.
#[serde(default)]
pub highlight: ColorPair,
/// Colors for block/box borders.
#[serde(default)]
pub border: ColorPair,
/// Colors for block/box borders around important content.
#[serde(default)]
pub border_highlight: ColorPair,
/// Text and border colors for error reporting.
#[serde(default)]
pub error: ColorPair,
}
impl Theme {
#[must_use]
pub fn default(&self) -> Style {
Style::default()
.bg(self.default.bg.unwrap_or(Color::Black))
.fg(self.default.fg.unwrap_or(Color::LightYellow))
}
#[must_use]
pub fn highlight(&self) -> Style {
Style::default()
.bg(self.highlight.bg.unwrap_or(Color::LightYellow))
.fg(self.highlight.fg.unwrap_or(Color::Black))
}
#[must_use]
pub fn border(&self) -> Style {
Style::default()
.bg(self.border.bg.unwrap_or(Color::Black))
.fg(self.border.fg.unwrap_or(Color::LightCyan))
}
#[must_use]
pub fn border_highlight(&self) -> Style {
Style::default()
.bg(self.border_highlight.bg.unwrap_or(Color::LightYellow))
.fg(self.border_highlight.fg.unwrap_or(Color::Cyan))
}
#[must_use]
pub fn error(&self) -> Style {
Style::default()
.bg(self.error.bg.unwrap_or(Color::LightYellow))
.fg(self.error.fg.unwrap_or(Color::LightRed))
}
}

440
common/src/crypto.rs Normal file
View File

@ -0,0 +1,440 @@
//! Key derivation, encryption, and authentication.
use std::iter;
use argon2::Argon2;
/// The length of the per-item password salt, in bytes.
pub use argon2::RECOMMENDED_SALT_LEN;
use block_padding::{Iso7816, RawPadding as _};
use chacha20poly1305::{
KeyInit as _, XChaCha20Poly1305,
aead::{Aead as _, KeySizeUser, Payload},
};
use chrono::{DateTime, Utc};
use crypto_common::typenum::Unsigned as _;
use rand::seq::IndexedRandom as _;
use serde::Serialize;
use zeroize::Zeroizing;
use crate::error::Result;
/// The length of the per-item authentication nonce, in bytes.
pub const NONCE_LEN: usize = 24;
/// The length of the padding block size, in bytes. The plaintext secret will be
/// padded before encryption, so that its length is a multiple of this block
/// size.
pub const PADDING_BLOCK_SIZE: usize = 256;
/// The set of characters that will be sampled for generating a strong, random
/// password.
///
/// These are ASCII-only letters, digits, and printable punctuation characters
/// easily available on a US English keyboard and should readily be accepted by
/// most systems.
pub const PASSWORD_CHARSET: &[u8] =
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,;:!?-+*/%=_@#$^&~()[]{}";
/// The length of randomly generated passwords.
///
/// This provides `log_2(87^40)` ~= 257 bits of entropy, or just over 32 bytes,
/// requiring around 10^77 guesses on average using brute force.
/// This should satisfy even the most stringent requirements.
pub const PASSWORD_LEN: usize = 40;
/// The pieces of data that are not encrypted but still validated using the
/// specified encryption password, for tamper detection.
///
/// Fields are in alphabetical order, so that round-tripping through `Value`
/// results in bitwise-identical JSON. (This is a precautionary measure.)
#[derive(Clone, Copy, Debug, Serialize)]
struct AdditionalData<'a> {
account: Option<&'a str>,
label: &'a str,
last_modified_at: DateTime<Utc>,
}
/// The result of encrypting and authenticating the secret, and authenticating
/// the additional data, using the specified password.
///
/// The salt for the Key
/// Derivation Function and the nonce for the authentication are generated
/// _inside_ the encryption function, so that the API ensures fresh,
/// cryptographically strong random values, so accidental re-use is prevented.
/// This means that the encryption function needs to return these as well.
#[derive(Clone, Debug)]
pub struct EncryptionOutput {
/// The already-encrypted and authenticated secret.
pub encrypted_secret: Vec<u8>,
/// The randomly-generated salt, used for seeding the KDF.
pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
/// The randomly-generated nonce, used for initializing the AEAD hash.
pub auth_nonce: [u8; NONCE_LEN],
}
/// The plain old data input for encryption, except for the password.
#[derive(Clone, Copy, Debug)]
pub struct EncryptionInput<'a> {
pub plaintext_secret: &'a [u8],
pub label: &'a str,
pub account: Option<&'a str>,
pub last_modified_at: DateTime<Utc>,
}
impl EncryptionInput<'_> {
/// Encrypts and authenticates the secret, and authenticates the additional
/// data, using a key derived from the `encryption_password`.
pub fn encrypt_and_authenticate(self, encryption_password: &[u8]) -> Result<EncryptionOutput> {
// Pad the secret to a multiple of the block size.
// Directly extending the String could re-allocate, which would leave
// the contents of the old allocation in the memory, without zeroizing it.
// To prevent this, what we do instead is pre-allocate a buffer of the
// required size, then copy the secret over, and perform the padding in
// the new buffer.
let unpadded_secret = self.plaintext_secret;
let total_len = (unpadded_secret.len() / PADDING_BLOCK_SIZE + 1) * PADDING_BLOCK_SIZE;
let mut padded_secret = Zeroizing::new(vec![0x00u8; total_len]);
padded_secret[..unpadded_secret.len()].copy_from_slice(unpadded_secret);
Iso7816::raw_pad(padded_secret.as_mut_slice(), unpadded_secret.len());
// Create the additional authenticated data.
let additional_data = AdditionalData {
account: self.account,
label: self.label,
last_modified_at: self.last_modified_at,
};
let additional_data_str = serde_json::to_string(&additional_data)?;
// Generate random salt and nonce. `rand::random()` uses a CSPRNG.
let kdf_salt: [u8; RECOMMENDED_SALT_LEN] = rand::random();
let auth_nonce: [u8; NONCE_LEN] = rand::random();
// Create KDF context.
// This uses recommended parameters (19 MB memory, 2 rounds, 1 degree of
// parallelism).
let hasher = Argon2::default();
// The actual encryption key is cleared (overwritten with all 0s) upon drop.
let mut key = Zeroizing::new([0u8; <XChaCha20Poly1305 as KeySizeUser>::KeySize::USIZE]);
hasher.hash_password_into(encryption_password, &kdf_salt, &mut *key)?;
// Create encryption and authentication context.
let aead = XChaCha20Poly1305::new_from_slice(key.as_slice())?;
// Actually perform the encryption and authentication.
let payload = Payload {
msg: padded_secret.as_slice(),
aad: additional_data_str.as_bytes(),
};
let encrypted_secret = aead.encrypt(<_>::from(&auth_nonce), payload)?;
Ok(EncryptionOutput {
encrypted_secret,
kdf_salt,
auth_nonce,
})
}
}
/// Plain old data input for decrypting and verifying the secret, and
/// verifying the authenticity of the non-encrypted additional data.
#[derive(Clone, Copy, Debug)]
pub struct DecryptionInput<'a> {
pub encrypted_secret: &'a [u8],
pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
pub auth_nonce: [u8; NONCE_LEN],
pub label: &'a str,
pub account: Option<&'a str>,
pub last_modified_at: DateTime<Utc>,
}
impl DecryptionInput<'_> {
/// Decrypts and verifies the secret, and verifies the additional data,
/// using a key derived from the `decryption_password`.
pub fn decrypt_and_verify(self, decryption_password: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
// Re-create the additional authenticated data. This helps detect when
// the displayed label or account have been tampered with in the database.
// This **must** be bitwise identical to the data used during encryption.
let additional_data = AdditionalData {
account: self.account,
label: self.label,
last_modified_at: self.last_modified_at,
};
let additional_data_str = serde_json::to_string(&additional_data)?;
// Create KDF context.
// This MUST use the same parameters as hashing during encryption.
let hasher = Argon2::default();
// The actual encryption key is cleared (overwritten with all 0s) upon drop.
let mut key = Zeroizing::new([0u8; <XChaCha20Poly1305 as KeySizeUser>::KeySize::USIZE]);
hasher.hash_password_into(decryption_password, &self.kdf_salt, &mut *key)?;
// Create decryption and verification context.
let aead = XChaCha20Poly1305::new_from_slice(key.as_slice())?;
// Actually perform the decryption and verification.
let payload = Payload {
msg: self.encrypted_secret,
aad: additional_data_str.as_bytes(),
};
let plaintext_secret = aead.decrypt(<_>::from(&self.auth_nonce), payload)?;
let mut plaintext_secret = Zeroizing::new(plaintext_secret);
// Un-pad the decrypted plaintext
let unpadded_len = Iso7816::raw_unpad(plaintext_secret.as_slice())?.len();
plaintext_secret.truncate(unpadded_len);
Ok(plaintext_secret)
}
}
/// Randomly generates a cryptographically strong (unpredictable) password.
pub fn generate_password() -> Zeroizing<String> {
// `rng()` returns a CSPRNG.
let mut rng = rand::rng();
iter::from_fn(|| PASSWORD_CHARSET.choose(&mut rng))
.copied()
.map(char::from)
.take(PASSWORD_LEN)
.collect::<String>()
.into()
}
#[cfg(test)]
mod tests {
use chrono::{Days, Utc};
use rand::{
Rng as _, RngCore as _,
distr::{SampleString as _, StandardUniform},
};
use zxcvbn::{Score, zxcvbn};
use super::{DecryptionInput, EncryptionInput, PADDING_BLOCK_SIZE, PASSWORD_LEN};
use crate::error::{Error, Result};
#[test]
fn correct_encryption_and_decryption_succeeds() -> Result<()> {
let timestamp = Utc::now();
let mut rng = rand::rng();
let p0 = vec![]; // empty payload edge case
let mut p1 = vec![0u8; PADDING_BLOCK_SIZE - 1];
let mut p2 = vec![0u8; PADDING_BLOCK_SIZE];
let mut p3 = vec![0u8; PADDING_BLOCK_SIZE + 1];
rng.fill_bytes(&mut p1);
rng.fill_bytes(&mut p2);
rng.fill_bytes(&mut p3);
for payload in [p0, p1, p2, p3] {
let password_len: usize = rng.random_range(8..64);
let password = StandardUniform.sample_string(&mut rng, password_len);
let encryption_input = EncryptionInput {
plaintext_secret: payload.as_slice(),
label: "the precise label does not matter",
account: Some("my uninteresting account name"),
last_modified_at: timestamp,
};
let output = encryption_input.encrypt_and_authenticate(password.as_bytes())?;
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: encryption_input.label,
account: encryption_input.account,
last_modified_at: timestamp,
};
let decrypted_secret = decryption_input.decrypt_and_verify(password.as_bytes())?;
assert_eq!(decrypted_secret.as_slice(), payload.as_slice());
}
Ok(())
}
#[test]
fn incorrect_password_fails_decryption() -> Result<()> {
let timestamp = Utc::now();
let mut rng = rand::rng();
let p0 = vec![]; // empty payload edge case
let mut p1 = vec![0u8; PADDING_BLOCK_SIZE - 1];
let mut p2 = vec![0u8; PADDING_BLOCK_SIZE];
let mut p3 = vec![0u8; PADDING_BLOCK_SIZE + 1];
rng.fill_bytes(&mut p1);
rng.fill_bytes(&mut p2);
rng.fill_bytes(&mut p3);
for payload in [p0, p1, p2, p3] {
let password_len: usize = rng.random_range(8..64);
let password = StandardUniform.sample_string(&mut rng, password_len);
let encryption_input = EncryptionInput {
plaintext_secret: payload.as_slice(),
label: "the precise label does not matter",
account: Some("my uninteresting account name"),
last_modified_at: timestamp,
};
let output = encryption_input.encrypt_and_authenticate(password.as_bytes())?;
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: encryption_input.label,
account: encryption_input.account,
last_modified_at: timestamp,
};
let wrong_password = b"this is NOT the right password!";
let result = decryption_input.decrypt_and_verify(wrong_password);
assert!(
matches!(
result,
Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
),
"unexpected result: {result:#?}",
);
}
Ok(())
}
#[test]
#[expect(clippy::allow_attributes_without_reason)]
#[expect(clippy::string_slice)]
fn altered_additional_data_fails_verification() -> Result<()> {
let timestamp = Utc::now();
let mut rng = rand::rng();
let p0 = vec![]; // empty payload edge case
let mut p1 = vec![0u8; PADDING_BLOCK_SIZE - 1];
let mut p2 = vec![0u8; PADDING_BLOCK_SIZE];
let mut p3 = vec![0u8; PADDING_BLOCK_SIZE + 1];
rng.fill_bytes(&mut p1);
rng.fill_bytes(&mut p2);
rng.fill_bytes(&mut p3);
for payload in [p0, p1, p2, p3] {
let password_len: usize = rng.random_range(8..64);
let password = StandardUniform.sample_string(&mut rng, password_len);
let encryption_input = EncryptionInput {
plaintext_secret: payload.as_slice(),
label: "the precise label does not matter",
account: Some("my uninteresting account name"),
last_modified_at: timestamp,
};
let output = encryption_input.encrypt_and_authenticate(password.as_bytes())?;
// Case #1: the account is altered (None instead of Some)
{
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: encryption_input.label,
account: None,
last_modified_at: timestamp,
};
let result = decryption_input.decrypt_and_verify(password.as_bytes());
assert!(
matches!(
result,
Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
),
"unexpected result: {result:#?}",
);
};
// Case #2: the label is (slightly) altered
{
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: &encryption_input.label[1..],
account: encryption_input.account,
last_modified_at: timestamp,
};
let result = decryption_input.decrypt_and_verify(password.as_bytes());
assert!(
matches!(
result,
Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
),
"unexpected result: {result:#?}",
);
};
// Case #2: the last modification date is tampered with
{
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: encryption_input.label,
account: encryption_input.account,
last_modified_at: timestamp.checked_sub_days(Days::new(1)).unwrap(),
};
let result = decryption_input.decrypt_and_verify(password.as_bytes());
assert!(
matches!(
result,
Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
),
"unexpected result: {result:#?}",
);
}
}
Ok(())
}
#[test]
fn generated_password_is_strong() {
for _ in 0..1024 {
let password = super::generate_password();
assert_eq!(password.len(), PASSWORD_LEN);
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_punct = password.chars().any(|c| c.is_ascii_punctuation());
let char_class_count = u32::from(has_lower)
+ u32::from(has_upper)
+ u32::from(has_digit)
+ u32::from(has_punct);
// Digits are not always found because their probability is relatively low,
// so assert that at least a reasonable variety of characters is exhibited.
assert!(char_class_count >= 3);
// Ensure that all characters are from the specified set.
assert!(password.chars().all(|c| {
!c.is_ascii_control()
&& (c.is_ascii_lowercase()
|| c.is_ascii_uppercase()
|| c.is_ascii_digit()
|| c.is_ascii_punctuation())
}));
// Evaluate password using the `zxcvbn` algorithm. It should never get anything
// but the maximal score, and it should not trigger any warnings/suggestions.
let entropy = zxcvbn(password.as_str(), &[]);
assert_eq!(entropy.score(), Score::Four);
assert!(entropy.feedback().is_none());
}
}
}

135
common/src/error.rs Normal file
View File

@ -0,0 +1,135 @@
//! Errors and results specific to Steelsafe.
use std::{
error::Error as StdError,
fmt::{self, Debug, Display, Formatter},
io::Error as IoError,
str::Utf8Error,
};
use arboard::Error as ClipboardError;
use argon2::Error as Argon2Error;
use block_padding::UnpadError;
use chacha20poly1305::Error as XChaCha20Poly1305Error;
use crypto_common::InvalidLength;
use logos_blockchain_zone_sdk::indexer::Error as ZoneIndexerError;
use nanosql::{Error as SqlError, rusqlite::Error as RusqliteError};
use serde_json::Error as JsonError;
use thiserror::Error;
use crate::error::Error::Context;
#[derive(Error)]
pub enum Error {
#[error("Can't re-open screen guard while one is already open")]
ScreenAlreadyOpen,
#[error("Can't find database directory")]
MissingDatabaseDir,
#[error("Label is required and must be a single line")]
LabelRequired,
#[error("Secret is required")]
SecretRequired,
#[error("Encryption (master) password is required and must be a single line")]
EncryptionPasswordRequired,
#[error("Passwords do not match")]
ConfirmPasswordMismatch,
#[error("Account name must be a single line if specified")]
AccountNameSingleLine,
#[error("No item is currently selected")]
SelectionRequired,
#[error("I/O error: {0}")]
Io(#[from] IoError),
#[error("Secret is not valid UTF-8: {0}")]
Utf8(#[from] Utf8Error),
#[error("JSON error: {0}")]
Json(#[from] JsonError),
#[error("Database error: {0}")]
Db(#[from] SqlError),
#[error("Rusqlite Database error: {0}")]
Sqlite(#[from] RusqliteError),
#[error("Database schema version too high: need <= {expected}, got {actual}")]
SchemaVersionMismatch { expected: i64, actual: i64 },
#[error("Password hashing error: {0}")]
Argon2(#[from] Argon2Error),
#[error("Encryption, decryption, or authentication error")]
XChaCha20Poly1305(#[from] XChaCha20Poly1305Error),
#[error("Invalid padding in decrypted secret")]
Unpad(#[from] UnpadError),
#[error(transparent)]
InvalidLength(#[from] InvalidLength),
#[error(transparent)]
Clipboard(#[from] ClipboardError),
#[error("{0}")]
InvalidChannelId(String),
#[error("URL parse error: {0}")]
Url(String),
#[error(transparent)]
ZoneIndexer(#[from] ZoneIndexerError),
#[error("{message}: {source}")]
Context {
message: String,
#[source]
source: Box<dyn StdError + Send + Sync + 'static>,
},
}
impl Error {
pub fn context<E, M>(source: E, message: M) -> Self
where
E: StdError + Send + Sync + 'static,
M: Into<String>,
{
Context {
message: message.into(),
source: Box::new(source),
}
}
}
impl Debug for Error {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
Display::fmt(self, formatter)
}
}
pub type Result<T, E = Error> = core::result::Result<T, E>;
pub trait ResultExt<T> {
fn context<M>(self, message: M) -> Result<T>
where
M: Into<String>;
}
impl<T, E> ResultExt<T> for Result<T, E>
where
E: StdError + Send + Sync + 'static,
{
fn context<M>(self, message: M) -> Result<T>
where
M: Into<String>,
{
self.map_err(|error| Error::context(error, message))
}
}

7
common/src/lib.rs Normal file
View File

@ -0,0 +1,7 @@
#![forbid(unsafe_code)]
pub mod config;
pub mod crypto;
pub mod error;
pub mod logging;
pub mod screen;

51
common/src/logging.rs Normal file
View File

@ -0,0 +1,51 @@
//! Logging utilities for TUI applications running in raw terminal mode.
use ratatui::crossterm::terminal;
/// A writer that confines log output to the bottom half of the terminal.
///
/// Before each write it:
/// 1. Sets the DECSTBM scroll region to the bottom half, so that any scroll
/// caused by new lines never displaces the top half.
/// 2. Moves the cursor to the last row of that region, so the newest line
/// always appears at the bottom and older lines scroll upward within the
/// region.
/// 3. Converts bare `\n` to `\r\n` for correct rendering in raw mode.
#[derive(Clone, Copy)]
pub struct RawModeWriter;
impl std::io::Write for RawModeWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let mut out = std::io::stdout().lock();
let (_, height) = terminal::size().unwrap_or((80, 24));
let top_row = height / 2 + 1; // 1-based: first row of bottom half
let bottom_row = height; // 1-based: last row of terminal
// Set scroll region to the bottom half, then move cursor to its last row.
// Any subsequent \n will scroll only within [top_row, bottom_row].
write!(out, "\x1b[{top_row};{bottom_row}r\x1b[{bottom_row};1H")?;
let mut start = 0;
for i in 0..buf.len() {
if buf[i] == b'\n' && (i == 0 || buf[i - 1] != b'\r') {
out.write_all(&buf[start..i])?;
out.write_all(b"\r\n")?;
start = i + 1;
}
}
out.write_all(&buf[start..])?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
std::io::stdout().flush()
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RawModeWriter {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
*self
}
}

105
common/src/screen.rs Normal file
View File

@ -0,0 +1,105 @@
//! A guard object that makes sure the screen and terminal mode
//! is always restored, even when an error or panic occurs.
use std::{
io::{self, Stdout},
ops::{Deref, DerefMut},
sync::atomic::{AtomicBool, Ordering},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
crossterm::{
ExecutableCommand as _,
event::{DisableMouseCapture, EnableMouseCapture},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
},
};
use crate::error::{Error, Result};
static IS_OPEN: AtomicBool = AtomicBool::new(false);
#[derive(Debug)]
pub struct ScreenGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl ScreenGuard {
pub fn open() -> Result<Self> {
let mut result = Err(Error::ScreenAlreadyOpen);
// only set the flag to true if we successfully acquired the terminal
let _ = IS_OPEN.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |flag| {
if flag {
result = Err(Error::ScreenAlreadyOpen);
return None;
}
if let Err(error) = terminal::enable_raw_mode() {
result = Err(error.into());
return None;
}
if let Err(error) = io::stdout().execute(EnterAlternateScreen) {
result = Err(error.into());
return None;
}
if let Err(error) = io::stdout().execute(EnableMouseCapture) {
result = Err(error.into());
return None;
}
match Terminal::new(CrosstermBackend::new(io::stdout())) {
Ok(terminal) => {
result = Ok(Self { terminal });
Some(true)
}
Err(error) => {
result = Err(error.into());
None
}
}
});
result
}
/*
pub fn close(mut self) -> Result<()> {
self.finalize()
}
*/
fn finalize() -> Result<()> {
terminal::disable_raw_mode()?;
io::stdout().execute(DisableMouseCapture)?;
io::stdout().execute(LeaveAlternateScreen)?;
IS_OPEN.store(false, Ordering::SeqCst);
Ok(())
}
}
impl Deref for ScreenGuard {
type Target = Terminal<CrosstermBackend<Stdout>>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl DerefMut for ScreenGuard {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
impl Drop for ScreenGuard {
fn drop(&mut self) {
if let Err(error) = Self::finalize() {
eprintln!("Error restoring terminal: {error:#}");
}
}
}

42
indexer/Cargo.toml Normal file
View File

@ -0,0 +1,42 @@
[package]
categories = { workspace = true }
description = "Sqlite zone indexer"
edition = { workspace = true }
keywords = { workspace = true }
license = { workspace = true }
name = "demo-sqlite-indexer"
readme = { workspace = true }
repository = { workspace = true }
version = { workspace = true }
[lints]
workspace = true
[dependencies]
arboard = "3.4.1"
chrono = { features = ["serde"], version = "0.4" }
clap = { features = ["derive", "env", "std"], version = "4" }
demo-sqlite-common = { workspace = true }
futures = { default-features = false, version = "0.3" }
hex = "0.4"
lb-common-http-client = { workspace = true }
lb-core = { workspace = true }
logos-blockchain-zone-sdk = { path = "../logos-blockchain/zone-sdk" }
nanosql = { features = ["chrono"], version = "0.10.0" }
rusqlite = { features = ["bundled"], version = "0.33" }
ratatui = { features = ["serde"], version = "0.29" }
reqwest = { features = ["json", "rustls-tls"], workspace = true }
tokio = { default-features = false, features = [
"io-std",
"io-util",
"macros",
"net",
"rt-multi-thread",
"signal",
"sync",
"time",
], version = "1" }
tracing = { workspace = true }
tracing-subscriber = { features = ["env-filter"], version = "0.3" }
tui-textarea = "0.7"
zeroize = { features = ["alloc"], workspace = true }

24
indexer/src/.steelsaferc Normal file
View File

@ -0,0 +1,24 @@
{
"theme": {
"default": {
"bg": "black",
"fg": "yellow"
},
"highlight": {
"bg": "gray",
"fg": "black"
},
"border": {
"bg": "black",
"fg": "bright_yellow"
},
"border_highlight": {
"bg": "gray",
"fg": "dark_gray"
},
"error": {
"bg": "red",
"fg": "white"
}
}
}

201
indexer/src/db.rs Normal file
View File

@ -0,0 +1,201 @@
//! Describes and implements the password database.
use std::{fs, path::Path};
use chrono::{DateTime, Utc};
use nanosql::{
AsSqlTy, Connection, ConnectionExt as _, FromSql, InsertInput, Null, Param, ResultRecord,
Table, ToSql, Value,
};
use crate::{
crypto::{NONCE_LEN, RECOMMENDED_SALT_LEN},
error::{Error, Result},
};
/// The current version of the database schema.
const SCHEMA_VERSION: i64 = 1;
/// Handle for the secrets database.
#[derive(Debug)]
pub struct DatabaseReadOnly {
connection: Connection,
}
impl DatabaseReadOnly {
/// Opens the database at the specified path.
pub fn open(path: &str) -> Result<Self> {
if let Some(parent) = Path::new(path).parent() {
fs::create_dir_all(parent)?;
}
let mut connection = Connection::connect(path)?;
connection.create_table::<Item>()?;
connection.create_table::<Metadata>()?;
let schema_version = Self::schema_version(&connection)?;
if SCHEMA_VERSION < schema_version {
return Err(Error::SchemaVersionMismatch {
expected: SCHEMA_VERSION,
actual: schema_version,
});
}
Ok(Self { connection })
}
pub fn execute_batch(&self, sql: &str) -> Result<()> {
self.connection.execute_batch(sql).map_err(Into::into)
}
/// Retrieves the schema version of the database.
/// If the schema version was not yet set (because the database was just
/// created), then the schema version of the currently-running steelsafe
/// process will be inserted (and returned).
fn schema_version(connection: &Connection) -> nanosql::Result<i64> {
// If the schema version is not yet stored in the DB, then insert it.
// Otherwise, leave the existing version (ignore the insertion).
// We do not use a transaction, because we would need to commit the
// insert first in order to reliably read back the inserted value
// anyway. So, we just check if the insert succeeded, and if it did,
// simply return the current version -- this also ensures atomicity.
let metadata = Metadata {
key: MetadataKey::SchemaVersion,
value: Value::Integer(SCHEMA_VERSION),
};
if connection.insert_or_ignore_one(metadata)?.is_some() {
Ok(SCHEMA_VERSION)
} else {
Self::metadata_by_key(connection, MetadataKey::SchemaVersion)
}
}
fn metadata_by_key<T: FromSql>(
connection: &Connection,
key: MetadataKey,
) -> nanosql::Result<T> {
let Metadata { ref value, .. } = connection.select_by_key(key)?;
let value = T::column_result(value.into())?;
Ok(value)
}
/// Returns the list of items in the database.
///
/// The returned data is human-readable: it contains fields such as the
/// identifying name/label/title of the entry, the optional account
/// information, and the date of creation/last modification. It does not
/// return binary data such as the encrypted secret, the KDF salt, or
/// the authentication nonce.
///
/// If the `search_term` is `None`, then all items are returned.
///
/// If the `search_term` is `Some(_)`, then only items matching the search
/// term will be returned. The search term is interpreted as an SQL
/// `LIKE` pattern. The pattern will be matched against the label and
/// the account name, and entries matching either will be returned.
pub fn list_items_for_display(&self, search_term: Option<&str>) -> Result<Vec<DisplayItem>> {
self.connection
.compile_invoke(ListItemsForDisplay, search_term)
.map_err(Into::into)
}
/// Retrieves a full item from the database based on its unique ID (primary
/// key). This includes encryption and authentication data: the
/// encrypted secret, the KDF salt, and the authentication nonce.
pub fn item_by_id(&self, id: u64) -> Result<Item> {
self.connection.select_by_key(id).map_err(Into::into)
}
}
/// Describes a secret item.
#[derive(Clone, PartialEq, Eq, Debug, Table, ResultRecord)]
#[nanosql(insert_input_ty = AddItemInput<'p>)]
pub struct Item {
/// Unique identifier of the item.
#[nanosql(pk)]
pub uid: u64,
/// Human-readable identifier of the item.
#[nanosql(unique)]
pub label: String,
/// Username, email address, etc. for identification. `None` if not
/// applicable.
pub account: Option<String>,
/// Last modification date of the item. If never modified, this is the
/// creation date.
pub last_modified_at: DateTime<Utc>,
/// The encrypted and authenticated password data.
/// Also contains a copy of the other fields for the purpose of tamper
/// protection.
pub encrypted_secret: Vec<u8>,
/// The salt for the key derivation function.
///
/// This is `UNIQUE`, acting as an additional line of defense against
/// salt re-use, which would result in two users with the same password
/// and salt getting identical encryption keys.
#[nanosql(unique)]
pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
/// The nonce for the authentication function.
///
/// This is `UNIQUE`, acting as an additional line of defense against
/// nonce re-use, which would allow breaking encryption/authentication.
#[nanosql(unique)]
pub auth_nonce: [u8; NONCE_LEN],
}
/// Used for adding an encrypted secret item to the database.
#[derive(Clone, Param, InsertInput)]
#[nanosql(table = Item)]
pub struct AddItemInput<'p> {
/// inserting a `NULL` into an `INTEGER PRIMARY KEY` auto-generates the PK
pub uid: Null,
pub label: &'p str,
pub account: Option<&'p str>,
pub last_modified_at: DateTime<Utc>,
pub encrypted_secret: &'p [u8],
pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
pub auth_nonce: [u8; NONCE_LEN],
}
/// Human-readable subset (projection) of the `Item` table.
/// Does not contain the secret or the encryption details (salt/nonce).
#[derive(Clone, Debug, ResultRecord)]
pub struct DisplayItem {
pub uid: u64,
pub label: String,
pub account: Option<String>,
pub last_modified_at: DateTime<Utc>,
}
/// Internal technical bookkeeping data (e.g., database version).
#[derive(Clone, Debug, Table, Param, ResultRecord)]
struct Metadata {
#[nanosql(pk)]
key: MetadataKey,
value: Value,
}
/// The kinds of metadata stored in the database.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, AsSqlTy, ToSql, FromSql, Param, ResultRecord)]
#[nanosql(rename_all = "lower_snake_case")]
enum MetadataKey {
/// The version of the database schema that determines its format.
SchemaVersion,
}
nanosql::define_query! {
/// The optional parameter is a search/filter term. It works with SQLite `LIKE` syntax.
/// If not provided, no filtering will be performed, and all items will be returned.
ListItemsForDisplay<'p>: Option<&'p str> => Vec<DisplayItem> {
r#"
SELECT
"item"."uid" AS "uid",
"item"."label" AS "label",
"item"."account" AS "account",
"item"."last_modified_at" AS "last_modified_at"
FROM "item"
WHERE ?1 IS NULL OR "item"."label" LIKE ?1 OR "item"."account" LIKE ?1
ORDER BY "item"."uid";
"#
}
}

120
indexer/src/indexer.rs Normal file
View File

@ -0,0 +1,120 @@
use std::fs;
use futures::StreamExt as _;
use lb_common_http_client::{BasicAuthCredentials, CommonHttpClient};
use lb_core::mantle::ops::channel::ChannelId;
use logos_blockchain_zone_sdk::adapter::NodeHttpClient;
use logos_blockchain_zone_sdk::indexer::ZoneIndexer;
use reqwest::Url;
use tracing::{error, info};
use crate::{db::DatabaseReadOnly, error::Error};
pub type Result<T> = std::result::Result<T, Error>;
pub struct Indexer {
zone_indexer: ZoneIndexer<NodeHttpClient>,
db_path: String,
}
fn parse_channel_id(channel_id_str: &str) -> Result<ChannelId> {
let decoded = hex::decode(channel_id_str).map_err(|_| {
Error::InvalidChannelId(format!(
"INDEXER_CHANNEL_ID must be a valid hex string, got: '{channel_id_str}'"
))
})?;
let channel_bytes: [u8; 32] = decoded.try_into().map_err(|v: Vec<u8>| {
Error::InvalidChannelId(format!(
"INDEXER_CHANNEL_ID must be exactly 64 hex characters (32 bytes), got {} characters ({} bytes)",
v.len() * 2,
v.len()
))
})?;
Ok(ChannelId::from(channel_bytes))
}
impl Indexer {
pub fn new(
db_path: &str,
node_endpoint: &str,
channel_path: &str,
node_auth_username: Option<String>,
node_auth_password: Option<String>,
) -> Result<Self> {
let node_url = Url::parse(node_endpoint).map_err(|e| Error::Url(e.to_string()))?;
let basic_auth = node_auth_username
.map(|username| BasicAuthCredentials::new(username, node_auth_password));
let channel_id_str = fs::read_to_string(channel_path).map_err(|e| {
Error::InvalidChannelId(format!("Failed to read channel path '{channel_path}': {e}"))
})?;
let channel_id = parse_channel_id(channel_id_str.trim())?;
info!("Channel ID: {}", hex::encode(channel_id.as_ref()));
let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), node_url);
let zone_indexer = ZoneIndexer::new(channel_id, node);
Ok(Self { zone_indexer, db_path: db_path.to_owned() })
}
pub async fn run(&self) {
let db = match DatabaseReadOnly::open(&self.db_path) {
Ok(db) => db,
Err(e) => {
error!("Failed to open database: {e}");
return;
}
};
loop {
info!("Connecting to zone block stream...");
let stream = match self.zone_indexer.follow().await {
Ok(s) => s,
Err(e) => {
error!("Failed to connect to block stream: {e}");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
};
info!("Connected to zone block stream");
futures::pin_mut!(stream);
while let Some(zone_msg) = stream.next().await {
let logos_blockchain_zone_sdk::ZoneMessage::Block(zone_block) = zone_msg else {
continue;
};
let sql_text = match String::from_utf8(zone_block.data) {
Ok(s) => s,
Err(e) => {
error!("Zone block data is not valid UTF-8: {e}");
continue;
}
};
let statements: Vec<&str> = sql_text
.lines()
.map(|l| l.trim().trim_end_matches(';').trim())
.filter(|s| !s.is_empty())
.collect();
if statements.is_empty() {
continue;
}
info!("Applying {} SQL statement(s)", statements.len());
for stmt in &statements {
if let Err(e) = db.execute_batch(stmt) {
error!("Failed to execute SQL '{}': {e}", stmt);
}
}
info!("Applied {} statement(s)", statements.len());
}
error!("Zone block stream ended, reconnecting...");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
}

112
indexer/src/lib.rs Normal file
View File

@ -0,0 +1,112 @@
#![forbid(unsafe_code)]
#![allow(clippy::allow_attributes_without_reason)]
pub mod db;
pub mod indexer;
pub use demo_sqlite_common::{config, crypto, error, screen};
mod tui;
use clap::Parser;
use demo_sqlite_common::logging::RawModeWriter;
use indexer::Indexer;
use tracing::{error, info};
use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _};
#[derive(Parser, Debug)]
#[command(about = "SQLite zone indexer - replay zone blocks into a local SQLite database")]
pub struct IndexerArgs {
/// Logos blockchain node HTTP endpoint
#[arg(
long,
default_value = "http://localhost:8080",
env = "INDEXER_NODE_ENDPOINT"
)]
pub node_url: String,
/// Path to the `SQLite` database file
#[arg(long, default_value = "./data/indexer.db", env = "INDEXER_DB_PATH")]
pub db_path: String,
/// Path to the channel ID file
#[arg(long, default_value = "./data/channel.txt")]
channel_path: String,
/// Basic auth username for node endpoint
#[arg(long, env = "INDEXER_NODE_AUTH_USERNAME")]
pub node_auth_username: Option<String>,
/// Basic auth password for node endpoint
#[arg(long, env = "INDEXER_NODE_AUTH_PASSWORD")]
pub node_auth_password: Option<String>,
}
use crate::{config::Config, db::DatabaseReadOnly, error::Result, screen::ScreenGuard, tui::State};
#[derive(Debug)]
struct App {
screen: ScreenGuard,
state: State,
}
impl App {
fn new(state: State) -> Result<Self> {
Ok(Self {
screen: ScreenGuard::open()?,
state,
})
}
/// The main run loop.
fn run_app(mut self) -> Result<()> {
while self.state.is_running() {
self.screen.draw(|frame| self.state.draw(frame))?;
self.state.handle_events();
}
Ok(())
}
}
#[expect(clippy::unused_async)]
pub async fn run(args: IndexerArgs) -> Result<()> {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer().with_writer(RawModeWriter))
.init();
info!("Sqlite Indexer starting up...");
info!(" Logos blockchain Node: {}", args.node_url);
let db = DatabaseReadOnly::open(&args.db_path)?;
let indexer = match Indexer::new(
&args.db_path,
&args.node_url,
&args.channel_path,
args.node_auth_username,
args.node_auth_password,
) {
Ok(i) => i,
Err(e) => {
error!("Indexer initialization failed: {e}");
std::process::exit(1);
}
};
info!("Indexer ready");
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build tokio runtime for indexer");
rt.block_on(indexer.run());
});
info!("Background indexer started");
let config = Config::from_rc_file()?;
let state = State::new(db, config.theme)?;
let app = App::new(state)?;
app.run_app()
}

8
indexer/src/main.rs Normal file
View File

@ -0,0 +1,8 @@
use clap::Parser as _;
use demo_sqlite_indexer::{IndexerArgs, run};
#[tokio::main]
async fn main() {
let args = IndexerArgs::parse();
drop(run(args).await);
}

522
indexer/src/tui.rs Normal file
View File

@ -0,0 +1,522 @@
#![allow(clippy::allow_attributes_without_reason)]
//! The bulk of the actual user interface logic.
use std::{
fmt::{self, Debug, Formatter},
ops::{ControlFlow, Deref, DerefMut},
time::Duration,
};
use arboard::Clipboard;
use ratatui::{
Frame,
crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind},
layout::{Constraint, Margin, Rect},
style::Modifier,
widgets::{
Clear, Paragraph, Row, Table, TableState,
block::{Block, BorderType},
},
};
use tui_textarea::TextArea;
use zeroize::Zeroizing;
use crate::{
config::Theme,
crypto::DecryptionInput,
db::{DatabaseReadOnly, DisplayItem},
error::{Error, Result},
};
/// The top-level UI state, the basis of rendering.
#[derive(Debug)]
pub struct State {
db: DatabaseReadOnly,
clipboard: ClipboardDebugWrapper,
theme: Theme,
is_running: bool,
passwd_entry: Option<PasswordEntryState>,
find: Option<FindItemState>,
popup_error: Option<Error>,
items: Vec<DisplayItem>,
table_state: TableState,
}
impl State {
pub fn new(db: DatabaseReadOnly, theme: Theme) -> Result<Self> {
let items = db.list_items_for_display(None)?;
let clipboard = ClipboardDebugWrapper(Clipboard::new()?);
let table_state =
TableState::new().with_selected(if items.is_empty() { None } else { Some(0) });
Ok(Self {
db,
clipboard,
theme,
is_running: true,
passwd_entry: None,
find: None,
popup_error: None,
items,
table_state,
})
}
/// Returns `true` as long as the application should run.
/// Once this returns `false`, the application will exit.
pub const fn is_running(&self) -> bool {
self.is_running
}
/// Top-level widget rendering.
pub fn draw(&mut self, frame: &mut Frame) {
let half_screen = {
let full = frame.area();
Rect {
height: full.height / 2,
..full
}
};
let bottom_input_height = 3;
let mut table_area = {
let mut area = half_screen;
area.height -= bottom_input_height;
area
};
let bottom_input_area = Rect {
x: table_area.x,
y: table_area.y + table_area.height,
width: table_area.width,
height: bottom_input_height,
};
let table = self.main_table();
if let Some(passwd_entry) = self.passwd_entry.as_mut() {
frame.render_widget(&passwd_entry.enc_pass, bottom_input_area);
} else if let Some(find_state) = self.find.as_mut() {
frame.render_widget(&find_state.search_term, bottom_input_area);
} else {
table_area = half_screen;
}
frame.render_stateful_widget(table, table_area, &mut self.table_state);
if let Some(error) = self.popup_error.as_ref() {
let margin = Margin {
horizontal: half_screen.width.saturating_sub(72 + 2) / 2,
vertical: half_screen.height.saturating_sub(3 + 2) / 2,
};
let dialog_area = half_screen.inner(margin);
let modal = self.error_modal(error);
frame.render_widget(Clear, dialog_area);
frame.render_widget(modal, dialog_area);
}
}
fn main_table(&self) -> Table<'static> {
Table::new(
self.items.iter().map(|item| {
Row::new([
item.label.clone(),
item.account.clone().unwrap_or_default(),
item.last_modified_at.format("%F %T").to_string(),
])
}),
[
Constraint::Percentage(40),
Constraint::Percentage(40),
Constraint::Min(24),
],
)
.header(
Row::new(["Title", "Username or account", "Modified at (UTC)"])
.style(self.theme.default().add_modifier(Modifier::BOLD)),
)
.row_highlight_style(Modifier::REVERSED)
.block(
Block::bordered()
.title(format!(
" SteelSafe v{} (read-only) ",
env!("CARGO_PKG_VERSION")
))
.title_bottom(" [C]opy secret ")
.title_bottom(" [F]ind ")
.title_bottom(" [1] First ")
.title_bottom(" [0] Last ")
.title_bottom(" [R]efresh ")
.title_bottom(" [Q]uit ")
.border_type(BorderType::Rounded)
.border_style(if self.main_table_has_focus() {
self.theme.border().add_modifier(Modifier::BOLD)
} else {
self.theme.border()
}),
)
.style(self.theme.default())
}
fn error_modal(&self, error: &Error) -> Paragraph<'static> {
let block = Block::bordered()
.title(" Error ")
.title_bottom(" <Esc> Close ")
.border_type(BorderType::Rounded)
.border_style(self.theme.error().add_modifier(Modifier::BOLD));
Paragraph::new(format!("\n{error}\n"))
.centered()
.block(block)
.style(self.theme.error())
}
/// Event polling and error handling.
pub fn handle_events(&mut self) {
if let Err(error) = self.handle_events_impl() {
self.popup_error = Some(error);
}
}
/// The bulk of the actual event handling logic.
fn handle_events_impl(&mut self) -> Result<()> {
if !event::poll(Duration::from_millis(50))? {
return Ok(());
}
let event = event::read()?;
let event = match self.handle_error_input(event)? {
ControlFlow::Break(()) => return Ok(()),
ControlFlow::Continue(event) => event,
};
let event = match self.handle_passwd_entry_input(event)? {
ControlFlow::Break(()) => return Ok(()),
ControlFlow::Continue(event) => event,
};
let event = match self.handle_find_input(event)? {
ControlFlow::Break(()) => return Ok(()),
ControlFlow::Continue(event) => event,
};
self.handle_main_table_event(&event)
}
/// Handles events when the main table has focus.
fn handle_main_table_event(&mut self, event: &Event) -> Result<()> {
if let Event::Mouse(mouse) = event {
match mouse.kind {
MouseEventKind::ScrollDown => {
self.table_state.select_next();
}
MouseEventKind::ScrollUp => {
self.table_state.select_previous();
}
_ => {}
}
return Ok(());
}
let Event::Key(key) = event else {
return Ok(());
};
if key.kind != KeyEventKind::Press {
return Ok(());
}
match key.code {
KeyCode::Up | KeyCode::Char('k' | 'K') => {
self.table_state.select_previous();
}
KeyCode::Down | KeyCode::Tab | KeyCode::Char('j' | 'J') => {
self.table_state.select_next();
}
KeyCode::Char('1') => {
self.table_state.select_first();
}
KeyCode::Char('0') => {
self.table_state.select_last();
}
KeyCode::Char('r' | 'R') => {
self.sync_data(true)?;
}
KeyCode::Char('c' | 'C') | KeyCode::Enter => {
self.passwd_entry = Some(PasswordEntryState::with_theme(self.theme.clone()));
}
KeyCode::Char('f' | 'F' | '/') => {
// if we are already in find mode, do NOT reset
// the search term, just give back focus.
if let Some(find_state) = self.find.as_mut() {
find_state.set_focus(true);
} else {
self.find = Some(FindItemState::with_theme(self.theme.clone()));
}
}
KeyCode::Char('q' | 'Q') => {
self.is_running = false;
}
_ => {}
}
Ok(())
}
/// Handles events when the error modal is open.
#[expect(clippy::unnecessary_wraps)]
fn handle_error_input(&mut self, event: Event) -> Result<ControlFlow<(), Event>> {
if self.popup_error.is_none() {
return Ok(ControlFlow::Continue(event));
}
if let Event::Key(evt) = event
&& evt.code == KeyCode::Esc
{
self.popup_error = None;
}
Ok(ControlFlow::Break(()))
}
/// Handles events for the password entry panel before decrypting a secret.
fn handle_passwd_entry_input(&mut self, event: Event) -> Result<ControlFlow<(), Event>> {
let Some(passwd_entry) = self.passwd_entry.as_mut() else {
return Ok(ControlFlow::Continue(event));
};
match event {
Event::Key(evt) => match evt.code {
KeyCode::Esc => {
self.passwd_entry = None;
}
KeyCode::Enter => {
let password = Zeroizing::new(passwd_entry.enc_pass.lines().join("\n"));
self.passwd_entry = None;
self.copy_secret_to_clipboard(&password)?;
}
KeyCode::Char('h' | 'H') if evt.modifiers.contains(KeyModifiers::CONTROL) => {
passwd_entry.toggle_show_enc_pass();
}
_ => {
passwd_entry.enc_pass.input(event);
}
},
_ => {
passwd_entry.enc_pass.input(event);
}
}
Ok(ControlFlow::Break(()))
}
/// Handles events for the Find panel.
fn handle_find_input(&mut self, event: Event) -> Result<ControlFlow<(), Event>> {
let Some(find_state) = self.find.as_mut() else {
return Ok(ControlFlow::Continue(event));
};
match event {
Event::Key(evt) => match evt.code {
KeyCode::Esc => {
self.find = None;
self.sync_data(true)?;
Ok(ControlFlow::Break(()))
}
KeyCode::Enter if find_state.has_focus => {
find_state.set_focus(false);
Ok(ControlFlow::Break(()))
}
_ if find_state.has_focus => {
find_state.search_term.input(event);
self.sync_data(true)?;
Ok(ControlFlow::Break(()))
}
_ => Ok(ControlFlow::Continue(event)),
},
_ => Ok(ControlFlow::Continue(event)),
}
}
/// Reloads the contents of the database from disk to memory.
/// If `adjust_selection` is set, the last item of the table
/// will be selected. This is useful after certain operations
/// that act destructively on the table state (e.g., search).
fn sync_data(&mut self, adjust_selection: bool) -> Result<()> {
let search_term = self.find.as_ref().and_then(|find_state| {
find_state
.search_term
.lines()
.first()
.map(|line| format!("%{}%", line.trim()))
});
self.items = self.db.list_items_for_display(search_term.as_deref())?;
#[expect(unused_parens)]
if (adjust_selection
&& !self.items.is_empty()
&& self
.table_state
.selected()
.is_none_or(|idx| idx >= self.items.len()))
{
self.table_state.select_last();
}
Ok(())
}
/// Actually copy the decrypted plaintext secret to the clipboard.
/// We can't zeroize the clipboard content, so we don't even bother.
fn copy_secret_to_clipboard(&mut self, enc_pass: &str) -> Result<()> {
let index = self
.table_state
.selected()
.ok_or(Error::SelectionRequired)?;
let uid = self.items[index].uid;
let item = self.db.item_by_id(uid)?;
let input = DecryptionInput {
encrypted_secret: &item.encrypted_secret,
kdf_salt: item.kdf_salt,
auth_nonce: item.auth_nonce,
label: item.label.as_str(),
account: item.account.as_deref(),
last_modified_at: item.last_modified_at,
};
let plaintext_secret = input.decrypt_and_verify(enc_pass.as_bytes())?;
// we do NOT use `String::from_utf8()`, because that would copy the
// bytes, and complicate correct zeroization of the secret on error.
let secret_str = std::str::from_utf8(&plaintext_secret)?;
self.clipboard.set_text(secret_str).map_err(Into::into)
}
/// The main table has focus when none of the other widgets do.
fn main_table_has_focus(&self) -> bool {
(self.find.is_none() || self.find.as_ref().is_some_and(|find| !find.has_focus))
&& self.passwd_entry.is_none()
&& self.popup_error.is_none()
}
}
#[derive(Debug)]
struct PasswordEntryState {
is_visible: bool,
enc_pass: TextArea<'static>,
theme: Theme,
}
impl PasswordEntryState {
fn with_theme(theme: Theme) -> Self {
let mut enc_pass = TextArea::default();
enc_pass.set_style(theme.default());
// set up text field style
let mut state = Self {
is_visible: false,
enc_pass,
theme,
};
state.set_visible(false);
state
}
fn toggle_show_enc_pass(&mut self) {
self.set_visible(!self.is_visible);
}
fn set_visible(&mut self, is_visible: bool) {
self.is_visible = is_visible;
if self.is_visible {
self.enc_pass.clear_mask_char();
} else {
self.enc_pass.set_mask_char('\u{25cf}');
}
let show_hide_title = format!(
" <^H> {} password ",
if self.is_visible { "Hide" } else { "Show" },
);
self.enc_pass.set_block(
Block::bordered()
.title(" Enter decryption (master) password ")
.title_bottom(" <Enter> OK ")
.title_bottom(" <Esc> Cancel ")
.title_bottom(show_hide_title)
.border_type(BorderType::Rounded)
.border_style(self.theme.border().add_modifier(Modifier::BOLD)),
);
}
}
#[derive(Debug)]
struct FindItemState {
search_term: TextArea<'static>,
has_focus: bool,
theme: Theme,
}
impl FindItemState {
fn with_theme(theme: Theme) -> Self {
let mut search_term = TextArea::default();
search_term.set_block(
Block::bordered()
.title(" Search term ")
.title_bottom(" <Enter> Focus secrets ")
.title_bottom(" <Esc> Exit search ")
.border_type(BorderType::Rounded),
);
let mut state = Self {
search_term,
has_focus: true,
theme,
};
state.set_focus(true);
state
}
fn set_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
let block = self.search_term.block().cloned().unwrap_or_default();
if self.has_focus {
self.search_term
.set_style(self.theme.default().add_modifier(Modifier::BOLD));
self.search_term
.set_block(block.border_style(self.theme.border().add_modifier(Modifier::BOLD)));
} else {
self.search_term.set_style(self.theme.default());
self.search_term
.set_block(block.border_style(self.theme.border()));
}
}
}
/// The sole purpose of this is to implement `Debug` so that it doesn't break
/// literally everything.
struct ClipboardDebugWrapper(Clipboard);
impl Debug for ClipboardDebugWrapper {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.debug_struct("Clipboard").finish_non_exhaustive()
}
}
impl Deref for ClipboardDebugWrapper {
type Target = Clipboard;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for ClipboardDebugWrapper {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}

@ -1 +1 @@
Subproject commit a40f26eb40efb750a7b0411a9359e2f3945dfe90 Subproject commit 379d6475fbba60978b62feb0f08b9b310d05e18b

View File

@ -24,6 +24,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/logos-blockchain" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/logos-blockchain" && pwd)"
DATA_DIR="$SCRIPT_DIR/data" DATA_DIR="$SCRIPT_DIR/data"
# Will be overwritten by env file if set there
BUILD_DIR="$SCRIPT_DIR"
# Colors # Colors
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@ -90,7 +93,7 @@ while [[ $# -gt 0 ]]; do
CHECKPOINT_PATH="$2" CHECKPOINT_PATH="$2"
shift 2 shift 2
;; ;;
--sequencer-node-endpoint) --indexer-node-endpoint)
INDEXER_NODE_ENDPOINT="$2" INDEXER_NODE_ENDPOINT="$2"
shift 2 shift 2
;; ;;
@ -151,18 +154,18 @@ mkdir -p "$DATA_DIR"
LOCAL_IP=$(ipconfig getifaddr en0 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost") LOCAL_IP=$(ipconfig getifaddr en0 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
# Check if binaries exist, if not build them # Check if binaries exist, if not build them
SEQUENCER_BIN="$REPO_ROOT/target/release/demo-sqlite-sequencer" SEQUENCER_BIN="$BUILD_DIR/target/release/demo-sqlite-sequencer"
INDEXER_BIN="$REPO_ROOT/target/release/demo-sqlite-indexer" INDEXER_BIN="$BUILD_DIR/target/release/demo-sqlite-indexer"
if [[ "$SERVICE" == "sequencer" ]]; then if [[ "$SERVICE" == "sequencer" ]]; then
echo -e "${YELLOW}Building sequencer...${NC}" echo -e "${YELLOW}Building sequencer...${NC}"
cd "$REPO_ROOT" cd "$SCRIPT_DIR"
cargo build --release -p demo-sqlite-sequencer cargo build --release -p demo-sqlite-sequencer
fi fi
if [[ "$SERVICE" == "indexer" ]]; then if [[ "$SERVICE" == "indexer" ]]; then
echo -e "${YELLOW}Building indexer...${NC}" echo -e "${YELLOW}Building indexer...${NC}"
cd "$REPO_ROOT" cd "$SCRIPT_DIR"
cargo build --release -p demo-sqlite-indexer cargo build --release -p demo-sqlite-indexer
fi fi

3
rust-toolchain.toml Normal file
View File

@ -0,0 +1,3 @@
[toolchain]
channel = "1.94.0"
components = ["clippy"]

45
sequencer/Cargo.toml Normal file
View File

@ -0,0 +1,45 @@
[package]
categories = { workspace = true }
description = "Sqlite sequencer"
edition = { workspace = true }
keywords = { workspace = true }
license = { workspace = true }
name = "demo-sqlite-sequencer"
readme = { workspace = true }
repository = { workspace = true }
version = { workspace = true }
[lints]
workspace = true
[dependencies]
arboard = "3.4.1"
chrono = { features = ["serde"], version = "0.4" }
clap = { features = ["derive", "env", "std"], version = "4" }
demo-sqlite-common = { workspace = true }
fs2 = "0.4"
hex = "0.4"
lb-common-http-client = { workspace = true }
lb-core = { workspace = true }
lb-key-management-system-service = { workspace = true }
logos-blockchain-zone-sdk = { path = "../logos-blockchain/zone-sdk" }
nanosql = { features = ["chrono"], version = "0.10.0" }
rand = { version = "0.9" }
ratatui = { features = ["serde"], version = "0.29" }
reqwest = { features = ["json", "rustls-tls"], workspace = true }
rusqlite = { features = ["bundled", "trace"], version = "0.33.0" }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { default-features = false, features = [
"io-std",
"io-util",
"macros",
"net",
"rt-multi-thread",
"signal",
"sync",
], version = "1" }
tracing = { workspace = true }
tracing-subscriber = { features = ["env-filter"], version = "0.3" }
tui-textarea = "0.7"
zeroize = { features = ["alloc"], workspace = true }

View File

@ -0,0 +1,24 @@
{
"theme": {
"default": {
"bg": "black",
"fg": "yellow"
},
"highlight": {
"bg": "gray",
"fg": "black"
},
"border": {
"bg": "black",
"fg": "bright_yellow"
},
"border_highlight": {
"bg": "gray",
"fg": "dark_gray"
},
"error": {
"bg": "red",
"fg": "white"
}
}
}

335
sequencer/src/db.rs Normal file
View File

@ -0,0 +1,335 @@
//! Describes and implements the password database.
use std::{fs, fs::File, io::Write as _, path::Path, sync::Mutex};
use chrono::{DateTime, Utc};
use nanosql::{
AsSqlTy, Connection, ConnectionExt as _, FromSql, InsertInput, Null, Param, ResultRecord,
Table, ToSql, Value,
rusqlite::trace::{TraceEvent, TraceEventCodes},
};
use crate::{
crypto::{NONCE_LEN, RECOMMENDED_SALT_LEN},
error::{Error, Result},
};
/// The current version of the database schema.
const SCHEMA_VERSION: i64 = 1;
static TRACE_FILE: Mutex<Option<File>> = Mutex::new(None);
/// Handle for the secrets database.
#[derive(Debug)]
pub struct Database {
connection: Connection,
}
impl Database {
/// Opens the database at the specified path.
pub fn open(path: &str, queue_path: &str) -> Result<Self> {
if let Some(parent) = Path::new(queue_path).parent() {
fs::create_dir_all(parent)?;
}
let queue_file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(queue_path)?;
*TRACE_FILE.lock().unwrap() = Some(queue_file);
if let Some(parent) = Path::new(path).parent() {
fs::create_dir_all(parent)?;
}
let mut connection = Connection::connect(path)?;
connection.create_table::<Item>()?;
connection.create_table::<Metadata>()?;
let schema_version = Self::schema_version(&connection)?;
if SCHEMA_VERSION < schema_version {
return Err(Error::SchemaVersionMismatch {
expected: SCHEMA_VERSION,
actual: schema_version,
});
}
connection.trace_v2(TraceEventCodes::SQLITE_TRACE_STMT, Some(Self::trace_fn));
Ok(Self { connection })
}
fn trace_fn(event: TraceEvent<'_>) {
if let TraceEvent::Stmt(stmt, _) = event
&& let Some(sql) = stmt.expanded_sql()
{
if sql.trim_start().to_uppercase().starts_with("SELECT") {
return;
}
if let Ok(mut guard) = TRACE_FILE.lock()
&& let Some(file) = guard.as_mut()
{
let normalized = sql.split_whitespace().collect::<Vec<_>>().join(" ");
let _unused = writeln!(file, "{normalized}");
}
}
}
/// Retrieves the schema version of the database.
/// If the schema version was not yet set (because the database was just
/// created), then the schema version of the currently-running steelsafe
/// process will be inserted (and returned).
fn schema_version(connection: &Connection) -> nanosql::Result<i64> {
// If the schema version is not yet stored in the DB, then insert it.
// Otherwise, leave the existing version (ignore the insertion).
// We do not use a transaction, because we would need to commit the
// insert first in order to reliably read back the inserted value
// anyway. So, we just check if the insert succeeded, and if it did,
// simply return the current version -- this also ensures atomicity.
let metadata = Metadata {
key: MetadataKey::SchemaVersion,
value: Value::Integer(SCHEMA_VERSION),
};
if connection.insert_or_ignore_one(metadata)?.is_some() {
Ok(SCHEMA_VERSION)
} else {
Self::metadata_by_key(connection, MetadataKey::SchemaVersion)
}
}
fn metadata_by_key<T: FromSql>(
connection: &Connection,
key: MetadataKey,
) -> nanosql::Result<T> {
let Metadata { ref value, .. } = connection.select_by_key(key)?;
let value = T::column_result(value.into())?;
Ok(value)
}
/// Returns the list of items in the database.
///
/// The returned data is human-readable: it contains fields such as the
/// identifying name/label/title of the entry, the optional account
/// information, and the date of creation/last modification. It does not
/// return binary data such as the encrypted secret, the KDF salt, or
/// the authentication nonce.
///
/// If the `search_term` is `None`, then all items are returned.
///
/// If the `search_term` is `Some(_)`, then only items matching the search
/// term will be returned. The search term is interpreted as an SQL
/// `LIKE` pattern. The pattern will be matched against the label and
/// the account name, and entries matching either will be returned.
pub fn list_items_for_display(&self, search_term: Option<&str>) -> Result<Vec<DisplayItem>> {
self.connection
.compile_invoke(ListItemsForDisplay, search_term)
.map_err(Into::into)
}
/// Creates a new entry in the database using an already-encrypted secret.
pub fn add_item(&self, input: AddItemInput<'_>) -> Result<Item> {
self.connection.insert_one(input).map_err(Into::into)
}
/// Retrieves a full item from the database based on its unique ID (primary
/// key). This includes encryption and authentication data: the
/// encrypted secret, the KDF salt, and the authentication nonce.
pub fn item_by_id(&self, id: u64) -> Result<Item> {
self.connection.select_by_key(id).map_err(Into::into)
}
}
/// Describes a secret item.
#[derive(Clone, PartialEq, Eq, Debug, Table, ResultRecord)]
#[nanosql(insert_input_ty = AddItemInput<'p>)]
pub struct Item {
/// Unique identifier of the item.
#[nanosql(pk)]
pub uid: u64,
/// Human-readable identifier of the item.
#[nanosql(unique)]
pub label: String,
/// Username, email address, etc. for identification. `None` if not
/// applicable.
pub account: Option<String>,
/// Last modification date of the item. If never modified, this is the
/// creation date.
pub last_modified_at: DateTime<Utc>,
/// The encrypted and authenticated password data.
/// Also contains a copy of the other fields for the purpose of tamper
/// protection.
pub encrypted_secret: Vec<u8>,
/// The salt for the key derivation function.
///
/// This is `UNIQUE`, acting as an additional line of defense against
/// salt re-use, which would result in two users with the same password
/// and salt getting identical encryption keys.
#[nanosql(unique)]
pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
/// The nonce for the authentication function.
///
/// This is `UNIQUE`, acting as an additional line of defense against
/// nonce re-use, which would allow breaking encryption/authentication.
#[nanosql(unique)]
pub auth_nonce: [u8; NONCE_LEN],
}
/// Used for adding an encrypted secret item to the database.
#[derive(Clone, Param, InsertInput)]
#[nanosql(table = Item)]
pub struct AddItemInput<'p> {
/// inserting a `NULL` into an `INTEGER PRIMARY KEY` auto-generates the PK
pub uid: Null,
pub label: &'p str,
pub account: Option<&'p str>,
pub last_modified_at: DateTime<Utc>,
pub encrypted_secret: &'p [u8],
pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
pub auth_nonce: [u8; NONCE_LEN],
}
/// Human-readable subset (projection) of the `Item` table.
/// Does not contain the secret or the encryption details (salt/nonce).
#[derive(Clone, Debug, ResultRecord)]
pub struct DisplayItem {
pub uid: u64,
pub label: String,
pub account: Option<String>,
pub last_modified_at: DateTime<Utc>,
}
/// Internal technical bookkeeping data (e.g., database version).
#[derive(Clone, Debug, Table, Param, ResultRecord)]
struct Metadata {
#[nanosql(pk)]
key: MetadataKey,
value: Value,
}
/// The kinds of metadata stored in the database.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, AsSqlTy, ToSql, FromSql, Param, ResultRecord)]
#[nanosql(rename_all = "lower_snake_case")]
enum MetadataKey {
/// The version of the database schema that determines its format.
SchemaVersion,
}
nanosql::define_query! {
/// The optional parameter is a search/filter term. It works with SQLite `LIKE` syntax.
/// If not provided, no filtering will be performed, and all items will be returned.
ListItemsForDisplay<'p>: Option<&'p str> => Vec<DisplayItem> {
r#"
SELECT
"item"."uid" AS "uid",
"item"."label" AS "label",
"item"."account" AS "account",
"item"."last_modified_at" AS "last_modified_at"
FROM "item"
WHERE ?1 IS NULL OR "item"."label" LIKE ?1 OR "item"."account" LIKE ?1
ORDER BY "item"."uid";
"#
}
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use nanosql::{
Error as NanosqlError, Null,
rusqlite::{Error as SqliteError, ErrorCode},
};
use super::{AddItemInput, Database};
use crate::{
crypto::{NONCE_LEN, RECOMMENDED_SALT_LEN},
error::{Error, Result},
};
#[test]
fn salt_uniqueness_is_enforced() -> Result<()> {
let db = Database::open(":memory:", ":queue:")?;
let salt: [u8; RECOMMENDED_SALT_LEN] = *b"Qk2Dw5aV65Ie8y7t";
let nonce_1: [u8; NONCE_LEN] = *b"lMVXTMT2z2giginHeWwIajy4";
let nonce_2: [u8; NONCE_LEN] = *b"rZNaJw3dBHmiqGhfUxLbjL6x";
let input_1 = AddItemInput {
uid: Null,
label: "Some label",
account: Some("first@account.com"),
last_modified_at: Utc::now(),
encrypted_secret: b"EncrYpt3d S3cre7!123",
kdf_salt: salt,
auth_nonce: nonce_1,
};
let input_2 = AddItemInput {
uid: Null,
label: "a completely different title",
account: Some("second@otherserver.org"),
last_modified_at: Utc::now(),
encrypted_secret: b"$#an0ther-c1pherteXt-of_diff3rent^LENGTH%",
kdf_salt: salt,
auth_nonce: nonce_2,
};
// We should be able to add the first item sucessfully.
let item = db.add_item(input_1)?;
assert_eq!(db.item_by_id(item.uid)?, item);
// The second item has an identical salt, so insertion must fail
// due to the violation of the UNIQUE constraint.
let error = db
.add_item(input_2)
.expect_err("item with duplicate salt added");
let Error::Db(NanosqlError::Sqlite(SqliteError::SqliteFailure(error, _))) = error else {
panic!("unexpected error: {error}");
};
assert_eq!(error.code, ErrorCode::ConstraintViolation);
Ok(())
}
#[test]
fn nonce_uniqueness_is_enforced() -> Result<()> {
let db = Database::open(":memory:", ":queue:")?;
let salt_1: [u8; RECOMMENDED_SALT_LEN] = *b"NdBIIex0BLnkThWH";
let salt_2: [u8; RECOMMENDED_SALT_LEN] = *b"xS8HYP2XAjgSnEOJ";
let nonce: [u8; NONCE_LEN] = *b"vb4yngPRSgEOrBLNGw8YcGpG";
let input_1 = AddItemInput {
uid: Null,
label: "Not a useful label",
account: Some("foo@bar.qux"),
last_modified_at: Utc::now(),
encrypted_secret: b"more stuff, I've run out of ideas",
kdf_salt: salt_1,
auth_nonce: nonce,
};
let input_2 = AddItemInput {
uid: Null,
label: "...but neither is this!",
account: Some("lol@wut.gov"),
last_modified_at: Utc::now(),
encrypted_secret: b"some different blob",
kdf_salt: salt_2,
auth_nonce: nonce,
};
// We should be able to add the first item sucessfully.
let item = db.add_item(input_1)?;
assert_eq!(db.item_by_id(item.uid)?, item);
// The second item has an identical nonce, so insertion must fail
// due to the violation of the UNIQUE constraint.
let error = db
.add_item(input_2)
.expect_err("item with duplicate nonce added");
let Error::Db(NanosqlError::Sqlite(SqliteError::SqliteFailure(error, _))) = error else {
panic!("unexpected error: {error}");
};
assert_eq!(error.code, ErrorCode::ConstraintViolation);
Ok(())
}
}

133
sequencer/src/lib.rs Normal file
View File

@ -0,0 +1,133 @@
#![forbid(unsafe_code)]
#![allow(clippy::allow_attributes_without_reason)]
pub mod db;
pub mod sequencer;
pub use demo_sqlite_common::{config, crypto, error, screen};
mod tui;
use std::sync::Arc;
use clap::Parser;
use demo_sqlite_common::logging::RawModeWriter;
use sequencer::Sequencer;
use tracing::{error, info};
use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _};
#[derive(Parser, Debug)]
#[command(about = "SQLite zone sequencer")]
pub struct SequencerArgs {
/// Logos blockchain node HTTP endpoint
#[arg(
long,
default_value = "http://localhost:8080",
env = "SEQUENCER_NODE_ENDPOINT"
)]
pub node_url: String,
/// Path to the `SQLite` database file
#[arg(long, default_value = "./data/database.db", env = "SEQUENCER_DB_PATH")]
pub db_path: String,
/// Path to the signing key file (created if it doesn't exist)
#[arg(
long,
default_value = "./data/sequencer.key",
env = "SEQUENCER_SIGNING_KEY_PATH"
)]
pub key_path: String,
/// Basic auth username for node endpoint
#[arg(long, env = "SEQUENCER_NODE_AUTH_USERNAME")]
pub node_auth_username: Option<String>,
/// Basic auth password for node endpoint
#[arg(long, env = "SEQUENCER_NODE_AUTH_PASSWORD")]
pub node_auth_password: Option<String>,
/// Path to the queue file for pending SQL statements
#[arg(long, default_value = "./data/queue.txt", env = "SEQUENCER_QUEUE_FILE")]
pub queue_file: String,
/// Path to the checkpoint file for crash recovery
#[arg(
long,
default_value = "./data/sequencer.checkpoint",
env = "CHECKPOINT_PATH"
)]
checkpoint_path: String,
/// Path to the channel ID file
#[arg(long, default_value = "./data/channel.txt", env = "CHANNEL_PATH")]
channel_path: String,
}
use crate::{config::Config, db::Database, error::Result, screen::ScreenGuard, tui::State};
#[derive(Debug)]
struct App {
screen: ScreenGuard,
state: State,
}
impl App {
fn new(state: State) -> Result<Self> {
Ok(Self {
screen: ScreenGuard::open()?,
state,
})
}
/// The main run loop.
fn run_app(mut self) -> Result<()> {
while self.state.is_running() {
self.screen.draw(|frame| self.state.draw(frame))?;
self.state.handle_events();
}
Ok(())
}
}
#[expect(clippy::unused_async)]
pub async fn run(args: SequencerArgs) -> Result<()> {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer().with_writer(RawModeWriter))
.init();
info!("Sqlite Sequencer starting up...");
info!(" Logos blockchain Node: {}", args.node_url);
let db = Database::open(&args.db_path, &args.queue_file)?;
let sequencer = match Sequencer::new(
&args.node_url,
&args.key_path,
args.node_auth_username,
args.node_auth_password,
&args.queue_file,
&args.checkpoint_path,
&args.channel_path,
) {
Ok(s) => Arc::new(s),
Err(e) => {
error!("Sequencer initialization failed: {e}");
std::process::exit(1);
}
};
info!("Sequencer ready");
let sequencer_clone = Arc::clone(&sequencer);
tokio::spawn(async move {
sequencer_clone.run_processing_loop().await;
});
info!("Background processor started");
let config = Config::from_rc_file()?;
let state = State::new(db, config.theme)?;
let app = App::new(state)?;
app.run_app()
}

8
sequencer/src/main.rs Normal file
View File

@ -0,0 +1,8 @@
use clap::Parser as _;
use demo_sqlite_sequencer::{SequencerArgs, run};
#[tokio::main]
async fn main() {
let args = SequencerArgs::parse();
drop(run(args).await);
}

207
sequencer/src/sequencer.rs Normal file
View File

@ -0,0 +1,207 @@
use std::{
fs,
fs::OpenOptions,
io,
io::{BufRead as _, BufReader},
path::Path,
time::Duration,
};
use fs2::FileExt as _;
use lb_common_http_client::{BasicAuthCredentials, CommonHttpClient};
use lb_core::mantle::ops::channel::ChannelId;
use lb_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
use logos_blockchain_zone_sdk::adapter::NodeHttpClient;
use logos_blockchain_zone_sdk::sequencer::{
Error as ZoneSequencerError, SequencerCheckpoint, SequencerHandle, ZoneSequencer,
};
use nanosql::rusqlite::Error as SqliteError;
use reqwest::Url;
use thiserror::Error;
use tokio::time::sleep;
use tracing::{debug, info};
#[derive(Debug, Error)]
pub enum SequencerError {
#[error("Zone sequencer error: {0}")]
ZoneSequencer(#[from] ZoneSequencerError),
#[error("URL parse error: {0}")]
Url(String),
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("SQLite error: {0}")]
Sqlite(#[from] SqliteError),
#[error("Invalid key file: expected {expected} bytes, got {actual}")]
InvalidKeyFile { expected: usize, actual: usize },
#[error("{0}")]
InvalidChannelId(String),
#[error("Timeout: {0}")]
Timeout(String),
}
pub type Result<T> = std::result::Result<T, SequencerError>;
/// The sequencer that handles transactions using the Zone SDK
pub struct Sequencer {
handle: SequencerHandle<NodeHttpClient>,
queue_file: String,
checkpoint_path: String,
}
/// Load signing key from file or generate a new one if it doesn't exist
fn load_or_create_signing_key(path: &Path) -> Result<Ed25519Key> {
if path.exists() {
debug!("Loading existing signing key from {:?}", path);
let key_bytes = fs::read(path)?;
if key_bytes.len() != ED25519_SECRET_KEY_SIZE {
return Err(SequencerError::InvalidKeyFile {
expected: ED25519_SECRET_KEY_SIZE,
actual: key_bytes.len(),
});
}
let key_array: [u8; ED25519_SECRET_KEY_SIZE] =
key_bytes.try_into().expect("length already checked");
Ok(Ed25519Key::from_bytes(&key_array))
} else {
debug!("Generating new signing key and saving to {:?}", path);
let mut key_bytes = [0u8; ED25519_SECRET_KEY_SIZE];
rand::RngCore::fill_bytes(&mut rand::rng(), &mut key_bytes);
fs::write(path, key_bytes)?;
Ok(Ed25519Key::from_bytes(&key_bytes))
}
}
fn save_checkpoint(path: &Path, checkpoint: &SequencerCheckpoint) {
let data = serde_json::to_vec(checkpoint).expect("failed to serialize checkpoint");
fs::write(path, data).expect("failed to write checkpoint file");
}
fn load_checkpoint(path: &Path) -> Option<SequencerCheckpoint> {
if !path.exists() {
return None;
}
let data = fs::read(path).expect("failed to read checkpoint file");
Some(serde_json::from_slice(&data).expect("failed to deserialize checkpoint"))
}
impl Sequencer {
pub fn new(
node_endpoint: &str,
signing_key_path: &str,
node_auth_username: Option<String>,
node_auth_password: Option<String>,
queue_file: &str,
checkpoint_path: &str,
channel_path: &str,
) -> Result<Self> {
let node_url = Url::parse(node_endpoint).map_err(|e| SequencerError::Url(e.to_string()))?;
info!("{}", node_url.clone().to_string());
let basic_auth = node_auth_username
.map(|username| BasicAuthCredentials::new(username, node_auth_password));
for path in [signing_key_path, checkpoint_path, channel_path] {
if let Some(parent) = Path::new(path).parent() {
fs::create_dir_all(parent)?;
}
}
let checkpoint = load_checkpoint(Path::new(&checkpoint_path));
if checkpoint.is_some() {
println!(" Restored checkpoint from {checkpoint_path}");
}
let signing_key = load_or_create_signing_key(Path::new(signing_key_path))?;
let channel_id = ChannelId::from(signing_key.public_key().to_bytes());
fs::write(channel_path, hex::encode(channel_id.as_ref()))
.expect("failed to write channel id");
let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), node_url);
let (zone_sequencer, handle) = ZoneSequencer::init(channel_id, signing_key, node, checkpoint);
zone_sequencer.spawn();
Ok(Self {
handle,
queue_file: queue_file.to_owned(),
checkpoint_path: checkpoint_path.to_owned(),
})
}
/// Drain the queue file and return all pending queries
fn queue_drain(&self) -> Result<Vec<String>> {
let file = OpenOptions::new()
.read(true)
.write(true)
.open(self.queue_file.clone())?;
file.lock_exclusive()?;
let reader = BufReader::new(&file);
let mut queue_vec = Vec::new();
for query in reader.lines() {
queue_vec.push(query?.clone());
}
file.set_len(0)?;
Ok(queue_vec)
}
/// Process all pending queries as a single inscription
async fn process_pending_batch(&self) -> Result<()> {
let pending = self.queue_drain()?;
if pending.is_empty() {
return Ok(());
}
let count = pending.len();
debug!("Processing batch of {} queries", count);
let data = pending.join("\n").into_bytes();
let result = self.handle.publish_message(data).await?;
info!(
"Inscription published with tx_hash: {:?}",
result.inscription_id
);
save_checkpoint(Path::new(&self.checkpoint_path), &result.checkpoint);
Ok(())
}
/// Check if the queue file is empty
pub fn queue_is_empty(&self) -> Result<bool> {
match fs::metadata(self.queue_file.clone()) {
Ok(meta) => Ok(meta.len() == 0),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(true),
Err(e) => Err(e.into()),
}
}
/// Background processing loop - call this in a spawned task
pub async fn run_processing_loop(&self) {
let poll_interval = Duration::from_millis(100);
loop {
let is_empty = match self.queue_is_empty() {
Ok(empty) => empty,
Err(e) => {
tracing::error!("Failed to check queue: {}", e);
sleep(poll_interval).await;
continue;
}
};
if is_empty {
sleep(poll_interval).await;
continue;
}
if let Err(e) = self.process_pending_batch().await {
tracing::error!("Batch processing failed: {}", e);
}
}
}
}

881
sequencer/src/tui.rs Normal file
View File

@ -0,0 +1,881 @@
#![allow(clippy::allow_attributes_without_reason)]
//! The bulk of the actual user interface logic.
use std::{
fmt::{self, Debug, Formatter},
mem,
ops::{ControlFlow, Deref, DerefMut},
time::Duration,
};
use arboard::Clipboard;
use nanosql::Utc;
use ratatui::{
Frame,
crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind},
layout::{Constraint, Margin, Offset, Rect},
style::Modifier,
text::Line,
widgets::{
Clear, Paragraph, Row, Table, TableState,
block::{Block, BorderType},
},
};
use tui_textarea::TextArea;
use zeroize::Zeroizing;
use crate::{
config::Theme,
crypto::{DecryptionInput, EncryptionInput},
db::{AddItemInput, Database, DisplayItem, Item},
error::{Error, Result},
};
/// The top-level UI state, the basis of rendering.
#[derive(Debug)]
pub struct State {
db: Database,
clipboard: ClipboardDebugWrapper,
theme: Theme,
is_running: bool,
passwd_entry: Option<PasswordEntryState>,
find: Option<FindItemState>,
new_item: Option<NewItemState>,
popup_error: Option<Error>,
items: Vec<DisplayItem>,
table_state: TableState,
}
impl State {
pub fn new(db: Database, theme: Theme) -> Result<Self> {
let items = db.list_items_for_display(None)?;
let clipboard = ClipboardDebugWrapper(Clipboard::new()?);
let table_state =
TableState::new().with_selected(if items.is_empty() { None } else { Some(0) });
Ok(Self {
db,
clipboard,
theme,
is_running: true,
passwd_entry: None,
find: None,
new_item: None,
popup_error: None,
items,
table_state,
})
}
/// Returns `true` as long as the application should run.
/// Once this returns `false`, the application will exit.
pub const fn is_running(&self) -> bool {
self.is_running
}
/// Top-level widget rendering.
pub fn draw(&mut self, frame: &mut Frame) {
let half_screen = {
let full = frame.area();
Rect {
height: full.height / 2,
..full
}
};
let bottom_input_height = 3;
let mut table_area = {
let mut area = half_screen;
area.height -= bottom_input_height;
area
};
let bottom_input_area = Rect {
x: table_area.x,
y: table_area.y + table_area.height,
width: table_area.width,
height: bottom_input_height,
};
let table = self.main_table();
if let Some(passwd_entry) = self.passwd_entry.as_mut() {
frame.render_widget(&passwd_entry.enc_pass, bottom_input_area);
} else if let Some(find_state) = self.find.as_mut() {
frame.render_widget(&find_state.search_term, bottom_input_area);
} else {
table_area = half_screen;
}
frame.render_stateful_widget(table, table_area, &mut self.table_state);
if let Some(error) = self.popup_error.as_ref() {
let margin = Margin {
horizontal: half_screen.width.saturating_sub(72 + 2) / 2,
vertical: half_screen.height.saturating_sub(3 + 2) / 2,
};
let dialog_area = half_screen.inner(margin);
let modal = self.error_modal(error);
frame.render_widget(Clear, dialog_area);
frame.render_widget(modal, dialog_area);
} else if let Some(new_item) = self.new_item.as_ref() {
let inputs_total_height = new_item.text_areas().len() as u16 * 3;
let margin = Margin {
horizontal: half_screen.width.saturating_sub(72 + 2) / 2,
vertical: half_screen.height.saturating_sub(inputs_total_height + 2) / 2,
};
let dialog_area = half_screen.inner(margin);
let outer = self.new_item_background(new_item);
frame.render_widget(Clear, dialog_area);
frame.render_widget(&outer, dialog_area);
let label_rect = Rect {
height: 3,
..outer.inner(dialog_area)
};
let desc_rect = label_rect.offset(Offset { x: 0, y: 3 });
let secret_rect = desc_rect.offset(Offset { x: 0, y: 3 });
let passwd_rect = secret_rect.offset(Offset { x: 0, y: 3 });
let confirm_rect = passwd_rect.offset(Offset { x: 0, y: 3 });
frame.render_widget(&new_item.label, label_rect);
frame.render_widget(&new_item.account, desc_rect);
frame.render_widget(&new_item.secret, secret_rect);
frame.render_widget(&new_item.enc_pass, passwd_rect);
frame.render_widget(&new_item.confirm, confirm_rect);
}
}
fn main_table(&self) -> Table<'static> {
Table::new(
self.items.iter().map(|item| {
Row::new([
item.label.clone(),
item.account.clone().unwrap_or_default(),
item.last_modified_at.format("%F %T").to_string(),
])
}),
[
Constraint::Percentage(40),
Constraint::Percentage(40),
Constraint::Min(24),
],
)
.header(
Row::new(["Title", "Username or account", "Modified at (UTC)"])
.style(self.theme.default().add_modifier(Modifier::BOLD)),
)
.row_highlight_style(Modifier::REVERSED)
.block(
Block::bordered()
.title(format!(" SteelSafe v{} ", env!("CARGO_PKG_VERSION")))
.title_bottom(" [C]opy secret ")
.title_bottom(" [F]ind ")
.title_bottom(" [1] First ")
.title_bottom(" [0] Last ")
.title_bottom(" [N]ew item ")
.title_bottom(" [Q]uit ")
.border_type(BorderType::Rounded)
.border_style(if self.main_table_has_focus() {
self.theme.border().add_modifier(Modifier::BOLD)
} else {
self.theme.border()
}),
)
.style(self.theme.default())
}
fn error_modal(&self, error: &Error) -> Paragraph<'static> {
let block = Block::bordered()
.title(" Error ")
.title_bottom(" <Esc> Close ")
.border_type(BorderType::Rounded)
.border_style(self.theme.error().add_modifier(Modifier::BOLD));
Paragraph::new(format!("\n{error}\n"))
.centered()
.block(block)
.style(self.theme.error())
}
fn new_item_background(&self, state: &NewItemState) -> Block<'static> {
Block::bordered()
.title(" New secret item ")
.title_top(Line::from(" <^G> Generate password ").right_aligned())
.title_bottom(" <Enter> Save ")
.title_bottom(" <Esc> Cancel ")
.title_bottom(format!(
" <^H> {} secret ",
if state.show_secret { "Hide" } else { "Show" }
))
.title_bottom(format!(
" <^E> {} encr passwd ",
if state.show_enc_pass { "Hide" } else { "Show" }
))
.border_type(BorderType::Rounded)
.style(self.theme.border_highlight())
.border_style(self.theme.border_highlight().add_modifier(Modifier::BOLD))
}
/// Event polling and error handling.
pub fn handle_events(&mut self) {
if let Err(error) = self.handle_events_impl() {
self.popup_error = Some(error);
}
}
/// The bulk of the actual event handling logic.
fn handle_events_impl(&mut self) -> Result<()> {
if !event::poll(Duration::from_millis(50))? {
return Ok(());
}
let event = event::read()?;
let event = match self.handle_error_input(event)? {
ControlFlow::Break(()) => return Ok(()),
ControlFlow::Continue(event) => event,
};
let event = match self.handle_passwd_entry_input(event)? {
ControlFlow::Break(()) => return Ok(()),
ControlFlow::Continue(event) => event,
};
let event = match self.handle_find_input(event)? {
ControlFlow::Break(()) => return Ok(()),
ControlFlow::Continue(event) => event,
};
let event = match self.handle_new_input(event)? {
ControlFlow::Break(()) => return Ok(()),
ControlFlow::Continue(event) => event,
};
self.handle_main_table_event(&event)
}
/// Handles events when the main table has focus.
#[expect(clippy::unnecessary_wraps)]
fn handle_main_table_event(&mut self, event: &Event) -> Result<()> {
if let Event::Mouse(mouse) = event {
match mouse.kind {
MouseEventKind::ScrollDown => {
self.table_state.select_next();
}
MouseEventKind::ScrollUp => {
self.table_state.select_previous();
}
_ => {}
}
return Ok(());
}
let Event::Key(key) = event else {
return Ok(());
};
if key.kind != KeyEventKind::Press {
return Ok(());
}
match key.code {
KeyCode::Up | KeyCode::Char('k' | 'K') => {
self.table_state.select_previous();
}
KeyCode::Down | KeyCode::Tab | KeyCode::Char('j' | 'J') => {
self.table_state.select_next();
}
KeyCode::Char('1') => {
self.table_state.select_first();
}
KeyCode::Char('0') => {
self.table_state.select_last();
}
KeyCode::Char('c' | 'C') | KeyCode::Enter => {
self.passwd_entry = Some(PasswordEntryState::with_theme(self.theme.clone()));
}
KeyCode::Char('f' | 'F' | '/') => {
// if we are already in find mode, do NOT reset
// the search term, just give back focus.
if let Some(find_state) = self.find.as_mut() {
find_state.set_focus(true);
} else {
self.find = Some(FindItemState::with_theme(self.theme.clone()));
}
}
KeyCode::Char('n' | 'N') => {
self.new_item = Some(NewItemState::with_theme(self.theme.clone()));
}
KeyCode::Char('q' | 'Q') => {
self.is_running = false;
}
_ => {}
}
Ok(())
}
/// Handles events when the error modal is open.
#[expect(clippy::unnecessary_wraps)]
fn handle_error_input(&mut self, event: Event) -> Result<ControlFlow<(), Event>> {
if self.popup_error.is_none() {
return Ok(ControlFlow::Continue(event));
}
if let Event::Key(evt) = event
&& evt.code == KeyCode::Esc
{
self.popup_error = None;
}
Ok(ControlFlow::Break(()))
}
/// Handles events for the password entry panel before decrypting a secret.
fn handle_passwd_entry_input(&mut self, event: Event) -> Result<ControlFlow<(), Event>> {
let Some(passwd_entry) = self.passwd_entry.as_mut() else {
return Ok(ControlFlow::Continue(event));
};
match event {
Event::Key(evt) => match evt.code {
KeyCode::Esc => {
self.passwd_entry = None;
}
KeyCode::Enter => {
let password = Zeroizing::new(passwd_entry.enc_pass.lines().join("\n"));
self.passwd_entry = None;
self.copy_secret_to_clipboard(&password)?;
}
KeyCode::Char('h' | 'H') if evt.modifiers.contains(KeyModifiers::CONTROL) => {
passwd_entry.toggle_show_enc_pass();
}
_ => {
passwd_entry.enc_pass.input(event);
}
},
_ => {
passwd_entry.enc_pass.input(event);
}
}
Ok(ControlFlow::Break(()))
}
/// Handles events for the Find panel.
fn handle_find_input(&mut self, event: Event) -> Result<ControlFlow<(), Event>> {
let Some(find_state) = self.find.as_mut() else {
return Ok(ControlFlow::Continue(event));
};
match event {
Event::Key(evt) => match evt.code {
KeyCode::Esc => {
self.find = None;
self.sync_data(true)?;
Ok(ControlFlow::Break(()))
}
KeyCode::Enter if find_state.has_focus => {
find_state.set_focus(false);
Ok(ControlFlow::Break(()))
}
_ if find_state.has_focus => {
find_state.search_term.input(event);
self.sync_data(true)?;
Ok(ControlFlow::Break(()))
}
_ => Ok(ControlFlow::Continue(event)),
},
_ => Ok(ControlFlow::Continue(event)),
}
}
/// Handles events for the "New item" dialog.
fn handle_new_input(&mut self, event: Event) -> Result<ControlFlow<(), Event>> {
// if the input text area is not open, ignore the event and give it back right
// away
let Some(new_item) = self.new_item.as_mut() else {
return Ok(ControlFlow::Continue(event));
};
match event {
Event::Key(evt) => match evt.code {
KeyCode::Esc => {
self.new_item = None;
}
KeyCode::Down | KeyCode::Tab => {
new_item.cycle_forward();
}
KeyCode::Up => {
new_item.cycle_back();
}
KeyCode::Enter => {
// close dialog even if an error occurred
let new_item = self
.new_item
.take()
.expect("just checked that new_item is Some");
let added = new_item.add_item(&self.db)?;
self.sync_data(false)?;
if let Some((idx, _item)) = self
.items
.iter()
.enumerate()
.rev() // the new item will _usually_ be the last one
.find(|(_idx, item)| item.uid == added.uid)
{
self.table_state.select(Some(idx));
}
}
KeyCode::Char('h' | 'H') if evt.modifiers.contains(KeyModifiers::CONTROL) => {
new_item.toggle_show_secret();
}
KeyCode::Char('e' | 'E') if evt.modifiers.contains(KeyModifiers::CONTROL) => {
new_item.toggle_show_enc_pass();
}
KeyCode::Char('g' | 'G') if evt.modifiers.contains(KeyModifiers::CONTROL) => {
new_item.generate_random_password();
}
_ => {
new_item.focused_text_area().input(event);
}
},
_ => {
new_item.focused_text_area().input(event);
}
}
Ok(ControlFlow::Break(()))
}
/// Reloads the contents of the database from disk to memory.
/// If `adjust_selection` is set, the last item of the table
/// will be selected. This is useful after certain operations
/// that act destructively on the table state (e.g., search).
fn sync_data(&mut self, adjust_selection: bool) -> Result<()> {
let search_term = self.find.as_ref().and_then(|find_state| {
find_state
.search_term
.lines()
.first()
.map(|line| format!("%{}%", line.trim()))
});
self.items = self.db.list_items_for_display(search_term.as_deref())?;
#[expect(unused_parens)]
if (adjust_selection
&& !self.items.is_empty()
&& self
.table_state
.selected()
.is_none_or(|idx| idx >= self.items.len()))
{
self.table_state.select_last();
}
Ok(())
}
/// Actually copy the decrypted plaintext secret to the clipboard.
/// We can't zeroize the clipboard content, so we don't even bother.
fn copy_secret_to_clipboard(&mut self, enc_pass: &str) -> Result<()> {
let index = self
.table_state
.selected()
.ok_or(Error::SelectionRequired)?;
let uid = self.items[index].uid;
let item = self.db.item_by_id(uid)?;
let input = DecryptionInput {
encrypted_secret: &item.encrypted_secret,
kdf_salt: item.kdf_salt,
auth_nonce: item.auth_nonce,
label: item.label.as_str(),
account: item.account.as_deref(),
last_modified_at: item.last_modified_at,
};
let plaintext_secret = input.decrypt_and_verify(enc_pass.as_bytes())?;
// we do NOT use `String::from_utf8()`, because that would copy the
// bytes, and complicate correct zeroization of the secret on error.
let secret_str = std::str::from_utf8(&plaintext_secret)?;
self.clipboard.set_text(secret_str).map_err(Into::into)
}
/// The main table has focus when none of the other widgets do.
fn main_table_has_focus(&self) -> bool {
(self.find.is_none() || self.find.as_ref().is_some_and(|find| !find.has_focus))
&& self.passwd_entry.is_none()
&& self.new_item.is_none()
&& self.popup_error.is_none()
}
}
#[derive(Debug)]
struct PasswordEntryState {
is_visible: bool,
enc_pass: TextArea<'static>,
theme: Theme,
}
impl PasswordEntryState {
fn with_theme(theme: Theme) -> Self {
let mut enc_pass = TextArea::default();
enc_pass.set_style(theme.default());
// set up text field style
let mut state = Self {
is_visible: false,
enc_pass,
theme,
};
state.set_visible(false);
state
}
fn toggle_show_enc_pass(&mut self) {
self.set_visible(!self.is_visible);
}
fn set_visible(&mut self, is_visible: bool) {
self.is_visible = is_visible;
if self.is_visible {
self.enc_pass.clear_mask_char();
} else {
self.enc_pass.set_mask_char('\u{25cf}');
}
let show_hide_title = format!(
" <^H> {} password ",
if self.is_visible { "Hide" } else { "Show" },
);
self.enc_pass.set_block(
Block::bordered()
.title(" Enter decryption (master) password ")
.title_bottom(" <Enter> OK ")
.title_bottom(" <Esc> Cancel ")
.title_bottom(show_hide_title)
.border_type(BorderType::Rounded)
.border_style(self.theme.border().add_modifier(Modifier::BOLD)),
);
}
}
#[derive(Debug)]
struct FindItemState {
search_term: TextArea<'static>,
has_focus: bool,
theme: Theme,
}
impl FindItemState {
fn with_theme(theme: Theme) -> Self {
let mut search_term = TextArea::default();
search_term.set_block(
Block::bordered()
.title(" Search term ")
.title_bottom(" <Enter> Focus secrets ")
.title_bottom(" <Esc> Exit search ")
.border_type(BorderType::Rounded),
);
let mut state = Self {
search_term,
has_focus: true,
theme,
};
state.set_focus(true);
state
}
fn set_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
let block = self.search_term.block().cloned().unwrap_or_default();
if self.has_focus {
self.search_term
.set_style(self.theme.default().add_modifier(Modifier::BOLD));
self.search_term
.set_block(block.border_style(self.theme.border().add_modifier(Modifier::BOLD)));
} else {
self.search_term.set_style(self.theme.default());
self.search_term
.set_block(block.border_style(self.theme.border()));
}
}
}
#[derive(Debug)]
struct NewItemState {
label: TextArea<'static>,
account: TextArea<'static>,
secret: TextArea<'static>,
enc_pass: TextArea<'static>,
confirm: TextArea<'static>,
focused: FocusedTextArea,
show_secret: bool,
show_enc_pass: bool,
theme: Theme,
}
impl NewItemState {
fn with_theme(theme: Theme) -> Self {
let mut state = Self {
label: TextArea::default(),
account: TextArea::default(),
secret: TextArea::default(),
enc_pass: TextArea::default(),
confirm: TextArea::default(),
focused: FocusedTextArea::default(),
show_secret: false,
show_enc_pass: false,
theme,
};
// set initial styles
state.set_show_secret(false);
state.set_show_enc_pass(false);
let props = [
("Title or label", true),
("Username or account", false),
("Secret (to be stored)", true),
("Encryption (master) password", true),
("Confirm master password", true),
];
let border_style = state.theme.border_highlight();
for (ta, (title, required)) in state.text_areas_mut().into_iter().zip(props) {
ta.set_block(
Block::bordered()
.title(format!(" {title} "))
.border_type(BorderType::Rounded)
.border_style(border_style),
);
ta.set_placeholder_text(if required { "Required" } else { "Optional" });
}
state.set_focused_text_area(FocusedTextArea::default());
state
}
fn text_areas(&self) -> Vec<&TextArea<'static>> {
vec![
&self.label,
&self.account,
&self.secret,
&self.enc_pass,
&self.confirm,
]
}
fn text_areas_mut(&mut self) -> Vec<&mut TextArea<'static>> {
vec![
&mut self.label,
&mut self.account,
&mut self.secret,
&mut self.enc_pass,
&mut self.confirm,
]
}
const fn focused_text_area(&mut self) -> &mut TextArea<'static> {
match self.focused {
FocusedTextArea::Label => &mut self.label,
FocusedTextArea::Account => &mut self.account,
FocusedTextArea::Secret => &mut self.secret,
FocusedTextArea::EncPass => &mut self.enc_pass,
FocusedTextArea::Confirm => &mut self.confirm,
}
}
fn set_focused_text_area(&mut self, which: FocusedTextArea) {
self.focused = which;
let highlight_style = self.theme.highlight();
for ta in self.text_areas_mut() {
if let Some(block) = ta.block() {
ta.set_block(block.clone().style(highlight_style));
}
}
let ta = self.focused_text_area();
if let Some(block) = ta.block() {
ta.set_block(
block
.clone()
.style(highlight_style.add_modifier(Modifier::BOLD)),
);
}
}
fn cycle_forward(&mut self) {
self.set_focused_text_area(self.focused.next());
}
fn cycle_back(&mut self) {
self.set_focused_text_area(self.focused.prev());
}
fn set_show_secret(&mut self, flag: bool) {
self.show_secret = flag;
if flag {
self.secret.clear_mask_char();
} else {
self.secret.set_mask_char('\u{25cf}');
}
}
fn set_show_enc_pass(&mut self, flag: bool) {
self.show_enc_pass = flag;
if flag {
self.enc_pass.clear_mask_char();
self.confirm.clear_mask_char();
} else {
self.enc_pass.set_mask_char('\u{25cf}');
self.confirm.set_mask_char('\u{25cf}');
}
}
fn toggle_show_secret(&mut self) {
self.set_show_secret(!self.show_secret);
}
fn toggle_show_enc_pass(&mut self) {
self.set_show_enc_pass(!self.show_enc_pass);
}
fn generate_random_password(&mut self) {
let password = crate::crypto::generate_password();
self.secret.select_all();
self.secret.insert_str(password.as_str());
}
fn add_item(self, db: &Database) -> Result<Item> {
let label = match self.label.lines() {
[line] if !line.trim().is_empty() => line.trim(),
_ => return Err(Error::LabelRequired),
};
let account = match self.account.lines() {
[] => None,
[line] => {
if line.trim().is_empty() {
None
} else {
Some(line.trim())
}
}
_ => return Err(Error::AccountNameSingleLine),
};
// Steal the contents of the secret and wrap it in a `Zeroizing`, so
// that it's cleared upon drop (even if an error occurs).
let secret_lines = Zeroizing::new(self.secret.into_lines());
let secret = match secret_lines.as_slice() {
[] => return Err(Error::SecretRequired),
[line] if line.is_empty() => return Err(Error::SecretRequired),
lines => Zeroizing::new(lines.join("\n")),
};
// Do the same to the encryption password.
let mut enc_pass_lines = Zeroizing::new(self.enc_pass.into_lines());
let enc_pass = match enc_pass_lines.as_mut_slice() {
[line] if !line.is_empty() => Zeroizing::new(mem::take(line)),
_ => return Err(Error::EncryptionPasswordRequired),
};
let confirm_pass_lines = Zeroizing::new(self.confirm.into_lines());
let confirm_pass = Zeroizing::new(confirm_pass_lines.join("\n"));
if enc_pass != confirm_pass {
return Err(Error::ConfirmPasswordMismatch);
}
let encryption_input = EncryptionInput {
plaintext_secret: secret.as_bytes(),
label,
account,
last_modified_at: Utc::now(),
};
let encryption_output = encryption_input.encrypt_and_authenticate(enc_pass.as_bytes())?;
db.add_item(AddItemInput {
uid: nanosql::Null, // generate fresh unique ID
label,
account,
last_modified_at: encryption_input.last_modified_at,
encrypted_secret: encryption_output.encrypted_secret.as_slice(),
kdf_salt: encryption_output.kdf_salt,
auth_nonce: encryption_output.auth_nonce,
})
}
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
enum FocusedTextArea {
#[default]
Label,
Account,
Secret,
EncPass,
Confirm,
}
impl FocusedTextArea {
const fn next(self) -> Self {
use FocusedTextArea::{Account, Confirm, EncPass, Label, Secret};
match self {
Label => Account,
Account => Secret,
Secret => EncPass,
EncPass => Confirm,
Confirm => Label,
}
}
const fn prev(self) -> Self {
use FocusedTextArea::{Account, Confirm, EncPass, Label, Secret};
match self {
Label => Confirm,
Account => Label,
Secret => Account,
EncPass => Secret,
Confirm => EncPass,
}
}
}
/// The sole purpose of this is to implement `Debug` so that it doesn't break
/// literally everything.
struct ClipboardDebugWrapper(Clipboard);
impl Debug for ClipboardDebugWrapper {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.debug_struct("Clipboard").finish_non_exhaustive()
}
}
impl Deref for ClipboardDebugWrapper {
type Target = Clipboard;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for ClipboardDebugWrapper {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}