Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 124 additions & 1 deletion storage/sqlite/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@

//! A `Backend` implementation for Sqlite whose transactions are `Send`, so it's usable
//! in an async context.
//!
//! # Security notes (Unix)
//!
//! The database can contain highly sensitive data (wallet databases store private keys
//! and optionally the seed phrase), so on Unix this backend takes care to never expose
//! it to other local users:
//!
//! * a missing database directory (including any missing parent components) is created
//! with owner-only permissions (0700);
//! * the database file is created with owner-only permissions (0600) and, if a
//! pre-existing database file is exposed to group/other users, its permissions are
//! repaired to 0600 on open;
//! * symlinks and non-regular files at the database path are rejected rather than
//! followed.
//!
//! Note that the permissions of pre-existing directories are left untouched, since they
//! may be shared with unrelated data. Keep in mind that the auxiliary files that Sqlite
//! itself creates next to the database (rollback journal, WAL, shared-memory) inherit
//! umask-derived default permissions and are only protected by the permissions of the
//! containing directory, so a pre-existing world-accessible database directory may
//! briefly expose those files.
//!
//! On non-Unix platforms the file-level hardening is not implemented (known gap); the
//! files are created by Sqlite with the platform-default permissions.

extern crate core;

Expand All @@ -33,6 +57,97 @@ use rusqlite::{Connection, OpenFlags, OptionalExtension};
use error::process_sqlite_error;
use storage_core::{Data, DbDesc, DbMapId, backend};

/// Ensure that the directory of the database file exists. If the directory (or any of its
/// missing parents) does not exist, it is created with owner-only permissions (0700),
/// which also protects the auxiliary files that Sqlite creates (rollback journal, WAL,
/// shared-memory, temporary files). The permissions of pre-existing directories are left
/// untouched, since they may be shared with unrelated data.
#[cfg(unix)]
fn ensure_private_directory(dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;

// Create the missing components one by one, tightening each directory created by
// this call to 0700 immediately. This avoids both the window in which a freshly
// created directory exists with umask-derived permissions (TOCTOU) and the issue of
// `create_dir_all` leaving intermediate components with such permissions.
let mut prefix = PathBuf::new();
for component in dir.components() {
prefix.push(component);
match std::fs::create_dir(&prefix) {
Ok(()) => {
std::fs::set_permissions(&prefix, std::fs::Permissions::from_mode(0o700))?;
}
// The component already exists; its permissions are deliberately left
// untouched (it may be shared with unrelated data).
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Comment on lines +80 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
create_dir returns AlreadyExists not only for an existing directory but also for an existing symlink or a regular file at that component, and both cases are silently accepted here. A symlinked path component is followed (e.g. db -> /tmp/attacker_dir), which redirects the database, its 0600 file, and all Sqlite auxiliary files into a location chosen by whoever could create that symlink — contradicting the module docs' claim that symlinks "are rejected rather than followed" and weakening the 0700 protection this function is supposed to guarantee. Consider checking symlink_metadata/file_type().is_dir() on the AlreadyExists branch (and rejecting symlinks, mirroring create_private_file), or using open with O_DIRECTORY | O_NOFOLLOW to verify each component.

Suggestion:

Suggested change
// The component already exists; its permissions are deliberately left
// untouched (it may be shared with unrelated data).
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
// Reject symlinked (or non-directory) components, mirroring the
// file-path handling in `create_private_file`.
if !std::fs::symlink_metadata(&prefix)
.map(|m| m.is_dir())
.unwrap_or(false)
{
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"database directory component exists but is not a directory",
));
}
}

// All the previous components exist (we have processed them above), so a
// `NotFound` here could only be caused by a concurrent change, which we
// don't try to paper over.
Err(err) => return Err(err),
}
}

Ok(())
}

/// Ensure that the parent directory of the database file exists. On Unix it is created
/// with owner-only permissions (see [`ensure_private_directory`]); on other platforms
/// the platform-default permissions are used, as the file-level hardening is not
/// implemented there (see the security notes in the module documentation).
#[cfg(unix)]
fn ensure_parent_dir(parent: &Path) -> std::io::Result<()> {
ensure_private_directory(parent)
}

#[cfg(not(unix))]
fn ensure_parent_dir(parent: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(parent)
}

/// Create the database file atomically with owner-only permissions (0600), so that its
/// (temporarily empty) contents are never observable by other users. Note that the
/// `mode` of `OpenOptions` is masked by the process umask, so an explicit
/// `set_permissions` call is needed to enforce 0600 in any environment.
///
/// If the file already exists:
/// * a non-regular file (e.g. a directory or a symlink) is rejected with an error
/// instead of being followed;
/// * if its permissions are exposed to group/other users, they are repaired to 0600.
/// Other (already private) permissions are left untouched, so that an intentionally
/// chosen, non-exposed mode is not silently overwritten.
///
/// Returns whether the file was created.
#[cfg(unix)]
fn create_private_file(path: &Path) -> std::io::Result<bool> {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

match std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path) {
Ok(_file) => {
// `.mode(0o600)` is masked by the umask, so enforce the mode explicitly.
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(true)
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
// Sensitive wallet data must not end up behind a symlink, and "repairing"
// something that isn't a regular file (e.g. a directory) would produce a
// confusing error from Sqlite later, so reject such paths up front.
if !std::fs::symlink_metadata(path)?.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"database path exists but is not a regular file",
));
}
// Only repair the permissions if the file is exposed to group/other users.
let mode = std::fs::metadata(path)?.permissions().mode();
if mode & 0o077 != 0 {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Comment on lines +134 to +144

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
TOCTOU window between the symlink/regular-file check and the permission repair: symlink_metadata(path), metadata(path), and set_permissions(path) each re-resolve path, so a local attacker with write access to the directory can swap the file (e.g. with a symlink or hardlink) between the check and the repair — or after the repair but before Sqlite opens it by path. The module docs claim symlinks are "rejected rather than followed", but this guarantee is not race-free. Consider pinning the inode instead: open the existing file with OpenOptionsExt::custom_flags(libc::O_NOFOLLOW) (plus write access), then fchmod on the held handle (e.g. via std::os::unix::fs::FileExt::set_permissions), so the check and repair operate on the same inode. Alternatively, soften the documentation to state the guarantee only holds absent a concurrent local attacker.

Suggestion:

Suggested change
if !std::fs::symlink_metadata(path)?.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"database path exists but is not a regular file",
));
}
// Only repair the permissions if the file is exposed to group/other users.
let mode = std::fs::metadata(path)?.permissions().mode();
if mode & 0o077 != 0 {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
let file = std::fs::OpenOptions::new()
.write(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
// All subsequent operations act on the pinned inode via the held handle.
let meta = file.metadata()?;
if !meta.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"database path exists but is not a regular file",
));
}
if meta.permissions().mode() & 0o077 != 0 {
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}

Ok(false)
Comment on lines +140 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
TOCTOU race between the regular-file/permission checks and the subsequent open by Sqlite: an attacker with write access to the directory can swap the checked file (e.g. replace it with a symlink) after symlink_metadata/metadata succeed but before Sqlite opens it, so the symlink rejection and the 0600 repair can be applied to a file that is no longer the one Sqlite uses. Consider documenting this as a known limitation in the module security notes (like the auxiliary-files caveat), or re-verifying the path (e.g. comparing symlink_metadata before/after) if stronger guarantees are needed.

Suggestion:

Suggested change
// Only repair the permissions if the file is exposed to group/other users.
let mode = std::fs::metadata(path)?.permissions().mode();
if mode & 0o077 != 0 {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(false)
// Note: this check is subject to a TOCTOU race with an attacker who can write
// to the containing directory; see the module security notes.
let mode = std::fs::metadata(path)?.permissions().mode();
if mode & 0o077 != 0 {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(false)

}
Err(err) => Err(err),
}
}

use crate::queries::SqliteQueries;

// Note: DbTx holds the mutex itself and locks it on every operation instead of just holding a lock
Expand Down Expand Up @@ -441,14 +556,22 @@ impl backend::Backend for Sqlite {

if let SqliteStorageMode::File(ref path) = self.backend {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(error::process_io_error)?;
ensure_parent_dir(parent).map_err(error::process_io_error)?;
} else {
return Err(storage_core::error::Fatal::Io(
std::io::ErrorKind::NotFound,
"Cannot find the parent directory".to_string(),
)
.into());
}

// Pre-create the database file with owner-only permissions so that Sqlite never
// creates it with the default (world-readable) permissions.
// Note: this hardening is Unix-only; on other platforms the file is created by
// Sqlite with the platform-default permissions (see the security notes in the
// module documentation).
#[cfg(unix)]
create_private_file(path).map_err(error::process_io_error)?;
Comment on lines +573 to +574

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
The security hardening is Unix-only. On non-Unix platforms (e.g., Windows), SQLite will still create the database file with default permissions, so wallet data may be readable by other local users there. If other platforms are supported, consider equivalent hardening (e.g., Windows ACL restriction) or at least document the known gap in the function's doc comment.

Suggestion:

Suggested change
#[cfg(unix)]
create_private_file(path).map_err(error::process_io_error)?;
// TODO(hardening): on non-Unix platforms the file is created by SQLite with
// default permissions; add equivalent ACL hardening or document the gap.
#[cfg(unix)]
create_private_file(path).map_err(error::process_io_error)?;

}

let queries = desc.db_maps().transform(queries::SqliteQuery::from_desc);
Expand Down
145 changes: 145 additions & 0 deletions storage/sqlite/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,148 @@ fn db_open_in_memory_named() {
assert!(dbtx.get(MAPID.0, b"hello").unwrap().is_none());
}
}

/// Verify that newly created (and pre-existing) wallet databases get owner-only permissions
/// on Unix, protecting the sensitive data (private keys, seed phrase) stored inside.
#[cfg(unix)]
mod permissions_tests {
use std::os::unix::fs::PermissionsExt;
use std::path::Path;

use super::Sqlite;
use storage_backend_test_suite::prelude::desc;
use storage_core::{DbDesc, backend::Backend};

fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}

fn make_desc() -> DbDesc {
desc(1)
}

#[test]
fn newly_created_db_has_owner_only_permissions() {
let tmp = tempfile::TempDir::new().unwrap();
let db_dir = tmp.path().join("subdir");
let db_path = db_dir.join("wallet.db");

let db = Sqlite::new(&db_path).open(make_desc()).unwrap();
drop(db);

assert_eq!(mode(&db_dir), 0o700, "directory must be 0700");
assert_eq!(mode(&db_path), 0o600, "database file must be 0600");
}

#[test]
fn insecure_permissions_of_existing_db_are_repaired() {
let tmp = tempfile::TempDir::new().unwrap();
let tmp = tmp.path();
let db_path = tmp.join("wallet.db");

// Create the database, then weaken the file permissions like a pre-fix installation.
// Note: the directory here is pre-existing (tempfile), so its permissions are left
// untouched (it may be shared with unrelated data); only the database file itself
// must be repaired.
let db = Sqlite::new(&db_path).open(make_desc()).unwrap();
drop(db);
std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o644)).unwrap();

// Re-open: the file permissions must be repaired.
let db = Sqlite::new(&db_path).open(make_desc()).unwrap();
drop(db);

assert_eq!(
mode(&db_path),
0o600,
"database permissions must be repaired"
);
}

#[test]
fn pre_existing_directory_permissions_are_left_untouched() {
let tmp = tempfile::TempDir::new().unwrap();
let db_dir = tmp.path().join("existing");
std::fs::create_dir(&db_dir).unwrap();
std::fs::set_permissions(&db_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
let db_path = db_dir.join("wallet.db");

let db = Sqlite::new(&db_path).open(make_desc()).unwrap();
drop(db);

// Document the intentional behavior: a pre-existing (possibly permissive) directory
// is not modified; only the database file itself is protected.
assert_eq!(
mode(&db_dir),
0o755,
"pre-existing directory permissions must be left untouched"
);
assert_eq!(mode(&db_path), 0o600, "database file must still be 0600");
}

#[test]
fn nested_directories_are_created_private() {
let tmp = tempfile::TempDir::new().unwrap();
let db_dir = tmp.path().join("level1").join("level2");
let db_path = db_dir.join("wallet.db");

let db = Sqlite::new(&db_path).open(make_desc()).unwrap();
drop(db);

// Every directory created by this call must be owner-only, including the
// intermediate components.
assert_eq!(
mode(&tmp.path().join("level1")),
0o700,
"intermediate directory must be 0700"
);
assert_eq!(mode(&db_dir), 0o700, "leaf directory must be 0700");
assert_eq!(mode(&db_path), 0o600, "database file must be 0600");
}

#[test]
fn already_private_permissions_are_left_untouched() {
let tmp = tempfile::TempDir::new().unwrap();
let tmp = tmp.path();
let db_path = tmp.join("wallet.db");

// Create the database, then restrict it to an owner-only, read-only mode, i.e.
// one that is not exposed to group/other users.
let db = Sqlite::new(&db_path).open(make_desc()).unwrap();
drop(db);
std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o400)).unwrap();

// Opening may fail afterwards (Sqlite needs write access), depending on the
// environment, but the intentionally chosen permissions must be left untouched
// either way.
let _ = Sqlite::new(&db_path).open(make_desc());
Comment on lines +336 to +339

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · medium
This test discards the Result of the re-open (let _ = ...), so it cannot distinguish "the repair logic intentionally preserved the private 0o400 mode" from "open failed early (e.g. before any chmod) and nothing was touched". If the repair branch in create_private_file regressed (e.g. always overwriting to 0o600 before attempting open), a future failure mode where open errors before chmod would still pass here. Consider asserting on the error kind (or successfully opening after temporarily restoring write permission) so the preservation path is actually exercised.

Suggestion:

Suggested change
// Opening may fail afterwards (Sqlite needs write access), depending on the
// environment, but the intentionally chosen permissions must be left untouched
// either way.
let _ = Sqlite::new(&db_path).open(make_desc());
let result = Sqlite::new(&db_path).open(make_desc());
// The permissions must be preserved regardless of the open outcome.
assert_eq!(
mode(&db_path),
0o400,
"already private permissions must not be overwritten"
);
// If the open happened to succeed, the Result should still be well-formed.
if let Ok(db) = result {
drop(db);
}

assert_eq!(
mode(&db_path),
0o400,
"already private permissions must not be overwritten"
);
}

#[test]
fn symlinked_database_path_is_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
let tmp = tmp.path();
let target = tmp.join("target.db");
std::fs::File::create(&target).unwrap();
let db_path = tmp.join("wallet.db");
std::os::unix::fs::symlink(&target, &db_path).unwrap();

// Sensitive wallet data must not end up behind a symlink.
assert!(Sqlite::new(&db_path).open(make_desc()).is_err());
Comment on lines +351 to +357

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · medium
This test only asserts that open() fails, but Sqlite would fail to open target.db anyway since it is an empty, non-database file, regardless of whether the symlink rejection exists. The test therefore passes even if the symlink check in create_private_file were removed, so it does not verify the security property it claims to. Assert on the specific error (e.g. check that the error kind/message matches the "not a regular file" rejection) or create a valid Sqlite database as the symlink target so only the rejection can cause the failure.

Suggestion:

Suggested change
let target = tmp.join("target.db");
std::fs::File::create(&target).unwrap();
let db_path = tmp.join("wallet.db");
std::os::unix::fs::symlink(&target, &db_path).unwrap();
// Sensitive wallet data must not end up behind a symlink.
assert!(Sqlite::new(&db_path).open(make_desc()).is_err());
let target = tmp.join("target.db");
std::fs::File::create(&target).unwrap();
let db_path = tmp.join("wallet.db");
std::os::unix::fs::symlink(&target, &db_path).unwrap();
// Sensitive wallet data must not end up behind a symlink; assert on the
// specific rejection, not just any open failure (Sqlite would also fail on
// an empty target file even without the check).
let err = Sqlite::new(&db_path).open(make_desc()).unwrap_err();
assert!(err.to_string().contains("not a regular file"), "unexpected error: {err}");

}

#[test]
fn directory_at_database_path_is_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
let db_path = tmp.path().join("wallet.db");
std::fs::create_dir(&db_path).unwrap();

// A clear error instead of a confusing failure from inside Sqlite.
assert!(Sqlite::new(&db_path).open(make_desc()).is_err());
}
}
Loading