mirror of
https://github.com/logos-blockchain/logos-sql-zone.git
synced 2026-06-07 02:09:43 +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
|
||||
|
||||
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.
|
||||
# Logos SQLite Zone Sequencer and Indexer Demo
|
||||
|
||||
This directory contains a reference implementation of a Sovereign Zone solution using the Logos Blockchain as a simple database server.
|
||||
## 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.
|
||||
@ -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.
|
||||
* **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`.
|
||||
|
||||
@ -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. |
|
||||
| `--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`.
|
||||
|
||||
|
||||
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)"
|
||||
DATA_DIR="$SCRIPT_DIR/data"
|
||||
|
||||
# Will be overwritten by env file if set there
|
||||
BUILD_DIR="$SCRIPT_DIR"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@ -90,7 +93,7 @@ while [[ $# -gt 0 ]]; do
|
||||
CHECKPOINT_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--sequencer-node-endpoint)
|
||||
--indexer-node-endpoint)
|
||||
INDEXER_NODE_ENDPOINT="$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")
|
||||
|
||||
# Check if binaries exist, if not build them
|
||||
SEQUENCER_BIN="$REPO_ROOT/target/release/demo-sqlite-sequencer"
|
||||
INDEXER_BIN="$REPO_ROOT/target/release/demo-sqlite-indexer"
|
||||
SEQUENCER_BIN="$BUILD_DIR/target/release/demo-sqlite-sequencer"
|
||||
INDEXER_BIN="$BUILD_DIR/target/release/demo-sqlite-indexer"
|
||||
|
||||
if [[ "$SERVICE" == "sequencer" ]]; then
|
||||
echo -e "${YELLOW}Building sequencer...${NC}"
|
||||
cd "$REPO_ROOT"
|
||||
cd "$SCRIPT_DIR"
|
||||
cargo build --release -p demo-sqlite-sequencer
|
||||
fi
|
||||
|
||||
if [[ "$SERVICE" == "indexer" ]]; then
|
||||
echo -e "${YELLOW}Building indexer...${NC}"
|
||||
cd "$REPO_ROOT"
|
||||
cd "$SCRIPT_DIR"
|
||||
cargo build --release -p demo-sqlite-indexer
|
||||
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