Secure

Public helpers in kstlib.secure: filesystem guardrails (GuardPolicy presets and the PathGuardrails utility so mail templates, attachments, and other file-based assets stay confined to trusted directories) and password hashing (hash_password, verify_password, needs_rehash) backed by Argon2id.

Tip

See Secure for the feature guide (filesystem guardrails and password hashing).

Quick overview

  • STRICT_POLICY enforces root auto-creation, forbids external paths, and validates POSIX permissions (≤ 0o700).

  • RELAXED_POLICY still pins everything to the root but skips permission enforcement so it fits sandboxed/Windows-friendly scenarios.

  • PathGuardrails resolves relative or absolute inputs, normalises them via Path.resolve(), and raises PathSecurityError whenever a path escapes the root, points to the wrong type, or resides on a different drive (Windows).

  • relax() clones an existing guardrail with a modified allow_external flag, which helps when you need to temporarily opt into external paths for migrations.

Configuration snippet

mail:
    filesystem:
        attachments_root: "~/.cache/kstlib/mail/attachments"
        inline_root: "~/.cache/kstlib/mail/inline"
        templates_root: "~/.cache/kstlib/mail/templates"
        allow_external_attachments: false
        allow_external_templates: false
        auto_create_roots: true
        enforce_permissions: true
        max_permission_octal: 448  # 0o700

Downstream modules instantiate guardrails using these settings so every file operation inherits the same hardening behavior.

Usage patterns

Guarding template directories

from pathlib import Path
from kstlib.secure import PathGuardrails, STRICT_POLICY

guard = PathGuardrails(Path("~/kstlib/templates"), policy=STRICT_POLICY)
template = guard.resolve_file("mailers/welcome.html")

Relaxing external access temporarily

from kstlib.secure import PathGuardrails

guard = PathGuardrails("/srv/kstlib", policy=STRICT_POLICY)
external_guard = guard.relax(allow_external=True)
external_guard.resolve_file("/opt/legacy/template.html")  # raises if file is missing

End-to-end guardrail demo

examples/secure/guardrails_demo.py
 1"""Show how filesystem guardrails accept safe paths and block traversal attempts."""
 2
 3from __future__ import annotations
 4
 5from pathlib import Path
 6
 7from kstlib.secure import RELAXED_POLICY, PathGuardrails, PathSecurityError
 8
 9
10def build_workspace(root: Path) -> None:
11    """Populate a sandbox with a small report file for the demo."""
12    reports = root / "reports"
13    reports.mkdir(parents=True, exist_ok=True)
14    report_file = reports / "daily.txt"
15    report_file.write_text("Daily conversions: 42", encoding="utf-8")
16
17
18def guardrails_demo() -> None:
19    """Resolve a safe file and demonstrate how traversal is denied."""
20    workspace = Path(__file__).with_name("workspace")
21    guard = PathGuardrails(workspace, policy=RELAXED_POLICY)
22    build_workspace(guard.root)
23
24    safe_report = guard.resolve_file("reports/daily.txt")
25    print(f"Report lives at: {safe_report}")
26
27    try:
28        guard.resolve_file("../etc/passwd")
29    except PathSecurityError as exc:
30        print(f"Traversal blocked: {exc}")
31
32
33if __name__ == "__main__":  # pragma: no cover - manual example
34    guardrails_demo()

Module reference

Security helpers (filesystem guardrails, policies, password hashing, errors).

class kstlib.secure.DirectoryPermissions[source]

Bases: object

POSIX directory permission constants.

PRIVATE

Owner-only access (0o700). Use for directories containing sensitive files like tokens or secrets.

Type:

int

SHARED_READ

Owner full, group/others read+execute (0o755). Use for directories with public content.

Type:

int

PRIVATE: int = 448
SHARED_READ: int = 493
class kstlib.secure.FilePermissions[source]

Bases: object

POSIX file permission constants.

READONLY

Owner read-only (0o400). Use for sensitive files like tokens, private keys, and secrets. File cannot be modified after creation.

Type:

int

READONLY_ALL

Read-only for all users (0o444). Use for public documents like certificates, CSRs, and public keys.

Type:

int

OWNER_RW

Owner read-write (0o600). Use for files that need to be modified, or temporarily to unlock read-only files before deletion.

Type:

int

OWNER_RWX

Owner read-write-execute (0o700). Use for directories containing sensitive files.

Type:

int

READONLY: int = 256
READONLY_ALL: int = 292
OWNER_RW: int = 384
OWNER_RWX: int = 448
class kstlib.secure.GuardPolicy(name, allow_external=False, auto_create_root=True, enforce_permissions=True, max_permission_octal=448)[source]

Bases: object

Configuration values defining how guardrails behave.

name

Human-friendly label used for diagnostics.

Type:

str

allow_external

When True, paths outside the root are accepted.

Type:

bool

auto_create_root

Automatically create the root directory when missing.

Type:

bool

enforce_permissions

Whether POSIX permissions should be validated.

Type:

bool

max_permission_octal

Maximum allowed permission mask (defaults to PRIVATE).

Type:

int

Example

>>> from kstlib.secure import GuardPolicy
>>> policy = GuardPolicy(name="custom", allow_external=False)
>>> policy.name
'custom'
name: str
allow_external: bool
auto_create_root: bool
enforce_permissions: bool
max_permission_octal: int
__init__(self, name: 'str', allow_external: 'bool' = False, auto_create_root: 'bool' = True, enforce_permissions: 'bool' = True, max_permission_octal: 'int' = 448) None -> None
exception kstlib.secure.InvalidPasswordHashError[source]

Bases: PasswordError

Raised when a stored value is not a valid Argon2 hash.

exception kstlib.secure.PasswordError[source]

Bases: KstlibError, RuntimeError

Raised when a password hashing operation cannot be completed.

class kstlib.secure.PathGuardrails(root, *, policy=GuardPolicy(name='strict', allow_external=False, auto_create_root=True, enforce_permissions=True, max_permission_octal=448))[source]

Bases: object

Validate and resolve paths relative to a trusted root.

Example

>>> import tempfile
>>> from kstlib.secure import PathGuardrails, RELAXED_POLICY
>>> with tempfile.TemporaryDirectory() as tmpdir:
...     guard = PathGuardrails(tmpdir, policy=RELAXED_POLICY)
...     guard.policy.name
'relaxed'
__init__(self, root: 'str | Path', *, policy: 'GuardPolicy' = GuardPolicy(name='strict', allow_external=False, auto_create_root=True, enforce_permissions=True, max_permission_octal=448)) 'None' -> None[source]

Initialise guardrails rooted at root while enforcing policy.

Raises:

PathSecurityError – If root does not exist or is not a directory.

property root: Path

Return the resolved guardrail root directory.

property policy: GuardPolicy

Return the policy associated with the guardrails.

resolve_file(self, candidate: 'str | Path') 'Path' -> Path[source]

Resolve candidate and ensure it points to an existing file.

Raises:

PathSecurityError – If path is not a file or is outside root.

resolve_directory(self, candidate: 'str | Path') 'Path' -> Path[source]

Resolve candidate and ensure it points to an existing directory.

Raises:

PathSecurityError – If path is not a directory or is outside root.

resolve_path(self, candidate: 'str | Path') 'Path' -> Path[source]

Resolve candidate relative to the guardrail root without type checks.

relax(self, *, allow_external: 'bool | None' = None) 'PathGuardrails' -> PathGuardrails[source]

Return a new guardrail instance with adjusted external allowances.

exception kstlib.secure.PathSecurityError[source]

Bases: KstlibError, RuntimeError

Raised when filesystem guardrails detect a security violation.

kstlib.secure.hash_password(password: 'str | bytes', *, time_cost: 'int | None' = None, memory_cost: 'int | None' = None, parallelism: 'int | None' = None) 'str' -> str[source]

Hash a password with Argon2id and return the encoded PHC string.

The cost knobs (time_cost, memory_cost, parallelism) resolve as kwargs > kstlib config > defaults and are clamped to the security floors (time_cost>=2, memory_cost>=19456 KiB, parallelism>=1). Defaults track the argon2-cffi 25.x / OWASP recommendation (time_cost=3, memory_cost=65536 KiB, parallelism=4).

Output sizing (hash_len, salt_len) is intentionally not a per-call argument: it must stay consistent across all stored hashes. Configure it once via secure.passwords.hash_len / secure.passwords.salt_len in kstlib.conf.yml (defaults hash_len=32, salt_len=16; floors hash_len>=16, salt_len>=16).

Parameters:
  • password (str | bytes) – Plaintext password as str (UTF-8 encoded) or bytes.

  • time_cost (int | None) – Number of iterations. Defaults to the cascade value.

  • memory_cost (int | None) – Memory usage in KiB. Defaults to the cascade value.

  • parallelism (int | None) – Number of parallel lanes. Defaults to the cascade value.

Returns:

The Argon2id hash encoded as a PHC string ($argon2id$...).

Raises:

PasswordError – If argon2-cffi is not installed, the password is not str or bytes, the password exceeds MAX_PASSWORD_LENGTH, or the underlying hashing operation fails.

Return type:

str

Example

>>> from kstlib.secure import hash_password
>>> hash_password("s3cret")  
'$argon2id$v=19$m=65536,t=3,p=4$...'
kstlib.secure.needs_rehash(stored_hash: 'str') 'bool' -> bool[source]

Return whether a stored hash should be recomputed with current parameters.

Compares the parameters embedded in stored_hash against the currently resolved policy (kstlib config or defaults). Returns True when the stored hash is weaker than the current parameters and should be upgraded on the next successful login.

Parameters:

stored_hash (str) – Previously stored PHC hash string.

Returns:

True if the hash should be regenerated, False otherwise.

Raises:
Return type:

bool

kstlib.secure.verify_password(password: 'str | bytes', stored_hash: 'str') 'bool' -> bool[source]

Verify a password against a stored Argon2id hash in constant time.

Parameters:
  • password (str | bytes) – Plaintext password to check (str or bytes).

  • stored_hash (str) – Previously stored PHC hash string.

Returns:

True if the password matches, False otherwise (including when the password exceeds MAX_PASSWORD_LENGTH).

Raises:
Return type:

bool

Example

>>> from kstlib.secure import hash_password, verify_password
>>> stored = hash_password("s3cret")  
>>> verify_password("s3cret", stored)  
True

Cost constants (DEFAULT_*, MIN_*, MAX_PASSWORD_LENGTH) and the hashing functions, defined in kstlib.secure.passwords:

Argon2id password hashing helpers.

This module wraps the optional argon2-cffi backend (install via pip install kstlib[passwords]) to provide password hashing and verification with safe, OWASP-aligned defaults.

Cost parameters follow a kwargs > kstlib config > defaults cascade. Any resolved parameter below the security floor is clamped up and a [SECURITY] warning is logged. Passwords and hashes are never written to the logs.

Example

>>> from kstlib.secure import hash_password, verify_password
>>> stored = hash_password("correct horse battery staple")  
>>> verify_password("correct horse battery staple", stored)  
True
exception kstlib.secure.passwords.InvalidPasswordHashError[source]

Bases: PasswordError

Raised when a stored value is not a valid Argon2 hash.

exception kstlib.secure.passwords.PasswordError[source]

Bases: KstlibError, RuntimeError

Raised when a password hashing operation cannot be completed.

kstlib.secure.passwords.hash_password(password: 'str | bytes', *, time_cost: 'int | None' = None, memory_cost: 'int | None' = None, parallelism: 'int | None' = None) 'str' -> str[source]

Hash a password with Argon2id and return the encoded PHC string.

The cost knobs (time_cost, memory_cost, parallelism) resolve as kwargs > kstlib config > defaults and are clamped to the security floors (time_cost>=2, memory_cost>=19456 KiB, parallelism>=1). Defaults track the argon2-cffi 25.x / OWASP recommendation (time_cost=3, memory_cost=65536 KiB, parallelism=4).

Output sizing (hash_len, salt_len) is intentionally not a per-call argument: it must stay consistent across all stored hashes. Configure it once via secure.passwords.hash_len / secure.passwords.salt_len in kstlib.conf.yml (defaults hash_len=32, salt_len=16; floors hash_len>=16, salt_len>=16).

Parameters:
  • password (str | bytes) – Plaintext password as str (UTF-8 encoded) or bytes.

  • time_cost (int | None) – Number of iterations. Defaults to the cascade value.

  • memory_cost (int | None) – Memory usage in KiB. Defaults to the cascade value.

  • parallelism (int | None) – Number of parallel lanes. Defaults to the cascade value.

Returns:

The Argon2id hash encoded as a PHC string ($argon2id$...).

Raises:

PasswordError – If argon2-cffi is not installed, the password is not str or bytes, the password exceeds MAX_PASSWORD_LENGTH, or the underlying hashing operation fails.

Return type:

str

Example

>>> from kstlib.secure import hash_password
>>> hash_password("s3cret")  
'$argon2id$v=19$m=65536,t=3,p=4$...'
kstlib.secure.passwords.needs_rehash(stored_hash: 'str') 'bool' -> bool[source]

Return whether a stored hash should be recomputed with current parameters.

Compares the parameters embedded in stored_hash against the currently resolved policy (kstlib config or defaults). Returns True when the stored hash is weaker than the current parameters and should be upgraded on the next successful login.

Parameters:

stored_hash (str) – Previously stored PHC hash string.

Returns:

True if the hash should be regenerated, False otherwise.

Raises:
Return type:

bool

kstlib.secure.passwords.verify_password(password: 'str | bytes', stored_hash: 'str') 'bool' -> bool[source]

Verify a password against a stored Argon2id hash in constant time.

Parameters:
  • password (str | bytes) – Plaintext password to check (str or bytes).

  • stored_hash (str) – Previously stored PHC hash string.

Returns:

True if the password matches, False otherwise (including when the password exceeds MAX_PASSWORD_LENGTH).

Raises:
Return type:

bool

Example

>>> from kstlib.secure import hash_password, verify_password
>>> stored = hash_password("s3cret")  
>>> verify_password("s3cret", stored)  
True