mirror of
https://github.com/logos-blockchain/logos-sql-zone.git
synced 2026-06-07 10:19:32 +00:00
independent sql zone repo
This commit is contained in:
parent
1c923e010b
commit
3e856a3a72
27
.env.example-local
Normal file
27
.env.example-local
Normal 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
8527
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
254
Cargo.toml
Normal file
254
Cargo.toml
Normal 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" }
|
||||||
26
README.md
26
README.md
@ -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
35
common/Cargo.toml
Normal 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
134
common/src/config.rs
Normal 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
440
common/src/crypto.rs
Normal 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
135
common/src/error.rs
Normal 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
7
common/src/lib.rs
Normal 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
51
common/src/logging.rs
Normal 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
105
common/src/screen.rs
Normal 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
42
indexer/Cargo.toml
Normal 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
24
indexer/src/.steelsaferc
Normal 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
201
indexer/src/db.rs
Normal 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
120
indexer/src/indexer.rs
Normal 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
112
indexer/src/lib.rs
Normal 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
8
indexer/src/main.rs
Normal 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
522
indexer/src/tui.rs
Normal 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
|
||||||
13
run-local.sh
13
run-local.sh
@ -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
3
rust-toolchain.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.94.0"
|
||||||
|
components = ["clippy"]
|
||||||
45
sequencer/Cargo.toml
Normal file
45
sequencer/Cargo.toml
Normal 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 }
|
||||||
24
sequencer/src/.steelsaferc
Normal file
24
sequencer/src/.steelsaferc
Normal 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
335
sequencer/src/db.rs
Normal 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
133
sequencer/src/lib.rs
Normal 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
8
sequencer/src/main.rs
Normal 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
207
sequencer/src/sequencer.rs
Normal 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
881
sequencer/src/tui.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user