Skip to content

security: create key files with 0o600 permissions atomically#495

Open
OscarOzaine wants to merge 1 commit into
Python-Cardano:mainfrom
OscarOzaine:security/key-file-permissions
Open

security: create key files with 0o600 permissions atomically#495
OscarOzaine wants to merge 1 commit into
Python-Cardano:mainfrom
OscarOzaine:security/key-file-permissions

Conversation

@OscarOzaine
Copy link
Copy Markdown

@OscarOzaine OscarOzaine commented Jun 6, 2026

The problem

CBORSerializable.save() (used to write .skey key files) had three weaknesses:

  1. World-readable files — files were created with the process umask (often 0o644), so other local users could read your secret keys.
  2. TOCTOU race — it checked "does the file exist?" and then opened it as a separate step, leaving a window an attacker could exploit.
  3. Symlink following — it would happily write through a pre-existing symlink.

The fix

save() now creates the file atomically with os.open(path, O_WRONLY | O_CREAT | O_EXCL, mode):

  • O_EXCL makes creation atomic — it fails if the path already exists (closing the race) and refuses to follow a pre-existing symlink.
  • The file is then chmod-ed to the requested mode regardless of umask.
  • Default mode is 0o600 (owner read/write only).
  • On any write failure, the partially-created file is cleaned up.

A new mode keyword lets callers of non-secret objects opt into wider permissions when they need it.

Behavior changes to be aware of

  • save() now refuses to overwrite any existing file (previously it only rejected non-empty files — an empty pre-existing file used to be allowed).
  • POSIX file modes are advisory on Windows; owner-only enforcement is POSIX-only. Windows users should rely on NTFS ACLs or disk encryption. This is now documented in the address guide.

Tests

All 574 tests pass. Tests were migrated from NamedTemporaryFile to pytest's tmp_path (so they don't pre-create the target file, which the new O_EXCL behavior rejects). New tests cover the 0o600 mode, overwrite refusal, and partial-write cleanup.

CBORSerializable.save() previously used a plain open(path, "w"), so key
files inherited the process umask (world-readable under the common 0o022)
and the os.path.isfile existence check was a TOCTOU race that also followed
symlinks.

save() now creates the file with os.open(O_WRONLY|O_CREAT|O_EXCL, mode)
followed by an explicit chmod, defaulting to 0o600 (owner read/write only).
O_EXCL makes creation atomic, closing the TOCTOU race and refusing to follow
or overwrite an existing path; FileExistsError is translated back to the
original IOError. A new mode keyword lets non-secret callers opt into wider
permissions. POSIX modes are advisory on Windows, which is documented.

Note: an empty pre-existing target is now also rejected (O_EXCL tightening),
where the old isfile/st_size check allowed it. Tests that pre-created temp
files via NamedTemporaryFile are migrated to pytest's tmp_path fixture.

Implements docs/specs/spec-02-key-file-permissions.md (PR 1 / P0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant