mirror of
https://github.com/logos-blockchain/logos-sql-zone.git
synced 2026-06-08 18:59:27 +00:00
336 lines
12 KiB
Rust
336 lines
12 KiB
Rust
//! 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(())
|
|
}
|
|
}
|