//! 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> = 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 { 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::()?; connection.create_table::()?; 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::>().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 { // 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( connection: &Connection, key: MetadataKey, ) -> nanosql::Result { 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> { 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 { 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 { 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, /// Last modification date of the item. If never modified, this is the /// creation date. pub last_modified_at: DateTime, /// The encrypted and authenticated password data. /// Also contains a copy of the other fields for the purpose of tamper /// protection. pub encrypted_secret: Vec, /// 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, 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, pub last_modified_at: DateTime, } /// 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 { 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(()) } }