Skip to content

fix: create Sqlite databases with owner-only permissions (CWE-732) - #2114

Merged
erubboli merged 3 commits into
masterfrom
fix/wallet-db-private-permissions
Sep 16, 2026
Merged

erubboli merged 3 commits into
masterfrom
fix/wallet-db-private-permissions

Conversation

@erubboli

@erubboli erubboli commented Sep 15, 2026

Copy link
Copy Markdown
Member

Fixes a High-severity local vulnerability (CWE-732, suggested CVSS 3.1: 7.8) in wallet database creation.

Issue

Wallet databases contain highly sensitive material: the root extended private key, the root VRF private key, and — when store-seed-phrase is enabled — the complete BIP-39 mnemonic and optional passphrase, in plaintext. With the common 0022 umask:

  • missing parent directories were created as 0755 (create_dir_all)
  • the database file was created by Sqlite as 0644

Any other local unprivileged user could copy a traversable database and recover full signing authority without the victim's RPC credentials.

Fix (storage/sqlite, Unix)

  • Directories: missing parent directories are created 0700 — this also shields the auxiliary Sqlite files (rollback journal, WAL, shared-memory, temporary files) under the same private directory boundary. Permissions of pre-existing directories are left untouched, since they may be shared with unrelated data.
  • Database file: created atomically with 0600 (pre-created via OpenOptions::create_new().mode(0o600) before Sqlite opens it), so it is never observable with looser permissions, even momentarily.
  • Repair: opening an existing database created by a pre-fix version tightens the file to 0600.

Non-Unix platforms keep the previous behavior.

Tests

Two new Unix-only tests in storage/sqlite:

  • newly created DB → directory 0700, file 0600
  • existing DB with 0644 file → repaired to 0600 on open

Verification

  • cargo test -p storage-sqlite: 27/27 ✓, cargo test -p wallet-controller: 24/24 ✓, cargo test -p wallet: 114/114 ✓
  • full ./do_checks.sh green under the CI toolchain (1.92.0)

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

🔍 OpenCodeReview found 6 issue(s) in this PR.

  • ✅ Successfully posted inline: 1 comment(s)
  • 📋 Routed to summary by policy: 4 comment(s)
  • ⏭️ Skipped (overlap with history): 1 comment(s)

test · low

📄 storage/sqlite/src/tests.rs (L356-L357)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category test)

The rejection tests assert only is_err(), which cannot distinguish a deliberate rejection (the implementation returns an io::Error with kind AlreadyExists and message "database path exists but is not a regular file") from an unrelated open failure. Asserting on the error kind/message would make a regression fail for the right reason and improve diagnostics.

💡 Suggested Change

Before:

        // Sensitive wallet data must not end up behind a symlink.
        assert!(Sqlite::new(&db_path).open(make_desc()).is_err());

After:

        let err = Sqlite::new(&db_path).open(make_desc()).unwrap_err();
        assert!(format!("{err}").contains("not a regular file"), "unexpected error: {err}");

other · low

📄 storage/sqlite/src/lib.rs (L135-L138)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category other)

The error message omits the offending path, which hurts diagnosability when a user hits this (e.g. a stray directory or symlink at the database location). Include the path in the message.

💡 Suggested Change

Before:

                return Err(std::io::Error::new(
                    std::io::ErrorKind::AlreadyExists,
                    "database path exists but is not a regular file",
                ));

After:

                return Err(std::io::Error::new(
                    std::io::ErrorKind::AlreadyExists,
                    format!("database path {} exists but is not a regular file", path.display()),
                ));

security · low

📄 storage/sqlite/src/lib.rs (L80-L82)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category security)

If a path component in the database directory already exists as a symlink to a directory, it is silently followed (AlreadyExists is skipped without inspecting what it is), so the database (and its WAL/journal files) can be created outside the intended location. This is consistent with the 'pre-existing directories are left untouched' policy, but unlike the file path — where symlinks are rejected — the directory path gives symlinked components a free pass. Consider at least documenting this asymmetry in the module security notes.

💡 Suggested Change

Before:

            // 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 => {}

After:

            // The component already exists; its permissions are deliberately left
            // untouched (it may be shared with unrelated data). Note that an existing
            // symlinked directory component is followed, unlike the database file itself,
            // where symlinks are rejected.
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}

other · low

📄 storage/sqlite/src/lib.rs (L573-L574)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category other)

The Ok(bool) ("was created") return value of create_private_file is discarded at the call site. Either use the flag (e.g. for logging) or simplify the signature to io::Result<()> to avoid a misleadingly informative API.

💡 Suggested Change

Before:

            #[cfg(unix)]
            create_private_file(path).map_err(error::process_io_error)?;

After:

            #[cfg(unix)]
            create_private_file(path).map_err(error::process_io_error)?; // result unused

Comment thread storage/sqlite/src/lib.rs Outdated
Comment on lines +66 to +69
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(false)
}

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 AlreadyExists branch unconditionally resets permissions to 0600 on every open. This silently repairs pre-existing files (good for upgrading old installations), but also silently overrides any intentional relaxation by the user (e.g., group-readable for backup tooling) on each launch. Consider documenting this repair-on-open behavior, or only repairing when permissions are world-readable, so user intent other than the insecure default is preserved.

Suggestion:

Suggested change
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(false)
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
// Only repair when the file is exposed to other users, so an intentionally
// relaxed (but still private-ish) configuration is not silently overwritten.
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)
}

Comment thread storage/sqlite/src/lib.rs
Comment on lines +496 to +497
#[cfg(unix)]
create_private_file(path).map_err(error::process_io_error)?;

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)?;

@erubboli

erubboli commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

Addressed the OpenCodeReview findings (run) in bb948a7:

Adopted:

  • Race-free directory ownership (comments 3+4): ensure_private_directory now attempts an atomic create_dir first and derives ownership from its result instead of a racy !dir.exists() check — this also removes the case where a directory created concurrently between the check and the chmod would have been tightened against the documented intent. Chose NotFound => create_dir_all + AlreadyExists => leave untouched over the suggested catch-all fallback so the ownership conclusion stays sound (NotFound proves the leaf didn't exist).
  • New test pre_existing_directory_permissions_are_left_untouched: creates a 0755 directory, opens the DB inside, and asserts the directory stays 0755 while the file becomes 0600 — pinning the intentional "pre-existing dirs are not modified" behavior (comment 2).

Declined:

  • Holding the file handle from create_private_file (comment 1): an open fd doesn't prevent another local process from unlink+recreating the path before SQLite opens it by path, so the suggested change doesn't actually narrow the TOCTOU window — it's security theater. The meaningful mitigations (open-by-fd, unsupported by rusqlite; or post-open inode verification) are disproportionate for a wallet directory under the user's own HOME.

Comment thread storage/sqlite/src/lib.rs Outdated
Comment on lines +77 to +78
match std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path) {
Ok(_file) => Ok(true),

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
Two gaps: (1) .mode(0o600) is ANDed with the process umask, so the resulting mode depends on the environment — unlike the directory case, no explicit set_permissions enforces 0600. (2) SQLite itself creates the rollback journal / WAL / SHM files next to the DB with umask-influenced default permissions (e.g. 0644); the pre-created DB file does not protect them. In the documented pre-existing-directory case (directory left at 0755), those sidecar files are world-readable while containing sensitive data. Consider an explicit set_permissions(0o600) after creation, and documenting/mitigating the sidecar-file exposure.

Suggestion:

Suggested change
match std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path) {
Ok(_file) => Ok(true),
let file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path);
match file {
Ok(_file) => {
// `mode` is masked by the umask; enforce 0600 explicitly for umask independence.
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(true)
}

@erubboli
erubboli force-pushed the fix/wallet-db-private-permissions branch from bb948a7 to f869714 Compare September 16, 2026 05:20
Comment thread storage/sqlite/src/lib.rs Outdated
Comment on lines +56 to +65
std::fs::create_dir_all(dir)?;
true
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => false,
Err(err) => return Err(err),
};

if created {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}

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
When intermediate parent components are missing, create_dir_all creates them with the process umask (potentially group/world-readable), and only the leaf is tightened to 0700. Sensitive intermediate directories on the wallet path (e.g. ~/.wallet//) can remain readable by others. Additionally there is a small TOCTOU window between directory creation and set_permissions(0700) during which the new leaf has umask permissions. Consider creating components one at a time with create_dir + immediate set_permissions, or tightening every component this call created.

Suggestion:

Suggested change
std::fs::create_dir_all(dir)?;
true
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => false,
Err(err) => return Err(err),
};
if created {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}
// Walk components so every directory this call creates gets 0700 immediately.
let mut prefix = None;
for component in dir.components() {
let mut next = std::path::PathBuf::new();
if let Some(p) = &prefix {
next.push(p);
}
next.push(component);
std::fs::create_dir(&next).ok();
let _ = std::fs::set_permissions(&next, std::fs::Permissions::from_mode(0o700));
prefix = Some(next);
}

@erubboli

Copy link
Copy Markdown
Member Author

Addressed the OpenCodeReview findings (run rounds 1–3) in cdad745:

Adopted:

  • Repair-on-open policy (rounds 1+2): the file permissions are now repaired to 0600 only when the file is actually exposed to group/other users (mode & 0o077 != 0); already-private modes are left untouched, and the policy is documented on create_private_file and in the module security notes.
  • Umask-independent creation (round 2): 0600 is now enforced with an explicit set_permissions after create_new, since the OpenOptions::mode is masked by the umask.
  • Non-regular paths (round 3): symlink_metadata + is_file check rejects directories and refuses to follow symlinks at the database path with a clear error instead of a confusing one from Sqlite later.
  • Intermediate directories (round 3): ensure_private_directory now walks the components and creates each missing one with create_dir + immediate set_permissions(0700), instead of create_dir_all leaving intermediates with umask-derived permissions. This also removes the chmod-after-create window (TOCTOU) for every created component; note std has no atomic mode-at-mkdir and adding a libc dep for this was not warranted.
  • cfg duplication (round 3): extracted a cfg-gated ensure_parent_dir helper; Backend::open is now single-line and platform-neutral.

Documented (declined as code changes):

  • Sqlite sidecar files (round 2): journal/WAL/SHM permissions are not controllable from outside Sqlite; documented that they are only protected by the containing directory's permissions (which is why the directory we create is 0700).
  • Non-Unix gap (round 1): documented in the module security notes and at the create_private_file call site; equivalent ACL hardening would be a separate effort.

New tests: nested_directories_are_created_private, symlinked_database_path_is_rejected, directory_at_database_path_is_rejected, already_private_permissions_are_left_untouched.

Comment thread storage/sqlite/src/lib.rs
Comment on lines +80 to +82
// 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 => {}

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",
));
}
}

Comment thread storage/sqlite/src/lib.rs
Comment on lines +134 to +144
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))?;
}

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))?;
}

Comment on lines +336 to +339
// 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());

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);
}

Comment on lines +351 to +357
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());

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}");

Wallet databases contain highly sensitive data: the root extended private
key, the root VRF private key, and (optionally) the BIP-39 mnemonic and
passphrase in plaintext. With the common 0022 umask, the database file was
created as 0644 and missing parent directories as 0755, allowing another
local unprivileged user (when the path is traversable) to copy the database
and recover the signing authority.

- Create missing parent directories as 0700, which also protects the
  auxiliary Sqlite files (rollback journal, WAL, shared-memory, temporary
  files) under the same private directory boundary. Permissions of
  pre-existing directories are left untouched, since they may be shared
  with unrelated data.
- Create the database file atomically as 0600 (pre-creating it before
  Sqlite opens it), so its contents are never observable with looser
  permissions, even momentarily.
- Repair the database file permissions to 0600 when opening an existing
  database created by a pre-fix version.

Unix only; other platforms keep the previous behavior.
Replace the racy exists() check with an atomic create_dir call: if the
leaf is created by this call it is tightened to 0700, if it already
existed its permissions are left untouched as documented. Pin the
latter behavior with a dedicated test.
- Repair the file permissions on open only when the file is actually
  exposed to group/other users, so an intentionally chosen, already
  private mode is not silently overwritten.
- Reject non-regular files (directories, symlinks) at the database path
  up front with a clear error instead of following them.
- Enforce 0600 explicitly after creating the file, since the mode of
  OpenOptions is masked by the process umask.
- Create missing directory components one by one, tightening each to
  0700 immediately, instead of relying on create_dir_all, which leaves
  intermediate components with umask-derived permissions.
- Extract an ensure_parent_dir helper to remove the cfg-gated
  duplication in Backend::open.
- Document the known gaps: Sqlite sidecar files are only protected by
  the containing directory's permissions, and the hardening is
  Unix-only.
- Add tests for nested directory creation, symlink/directory rejection
  and preservation of already-private permissions.
@erubboli
erubboli force-pushed the fix/wallet-db-private-permissions branch from cdad745 to 00eb68a Compare September 16, 2026 10:53
Comment thread storage/sqlite/src/lib.rs
Comment on lines +140 to +145
// 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)

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)

@erubboli
erubboli merged commit eebe758 into master Sep 16, 2026
21 checks passed
@erubboli
erubboli deleted the fix/wallet-db-private-permissions branch September 16, 2026 12:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants