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
39 changes: 32 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,26 @@ jobs:
- name: Install built wheel
run: |
python -m venv /tmp/qe-wheel-test
. /tmp/qe-wheel-test/bin/activate
python -m pip install dist/*.whl
quantum-encryptor-agent health --json
/tmp/qe-wheel-test/bin/python -m pip install dist/*.whl
cd "$(mktemp -d)"
/tmp/qe-wheel-test/bin/quantum-encryptor-agent health --json
/tmp/qe-wheel-test/bin/python -I - <<'PY'
from pathlib import Path
import sys

import api_app
import pqc_agent_tools

prefix = Path(sys.prefix).resolve()
for module in (api_app, pqc_agent_tools):
module_path = Path(module.__file__).resolve()
assert module_path.is_relative_to(prefix), (module.__name__, module_path, prefix)
PY

- name: Smoke installed web UI
run: |
. /tmp/qe-wheel-test/bin/activate
PORT=4002 python -m api_app &
cd "$(mktemp -d)"
PORT=4002 /tmp/qe-wheel-test/bin/python -I -m api_app &
server_pid=$!
trap 'kill "$server_pid"' EXIT
for attempt in $(seq 1 30); do
Expand All @@ -121,6 +133,12 @@ jobs:
python-version: "3.13"
cache: pip

- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm

- name: Install security tooling
run: python -m pip install -r requirements-dev.txt

Expand All @@ -133,8 +151,11 @@ jobs:
- name: Audit locked development dependencies
run: python -m pip_audit -r requirements-dev-lock.txt

- name: Audit web dependencies
run: npm audit --package-lock-only --audit-level=high

- name: Run Python security lint
run: python -m bandit -q -r crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py
run: python -m bandit -q -r api_app.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py

web:
name: Custom web UI
Expand Down Expand Up @@ -239,7 +260,11 @@ jobs:

- name: Build and install native liboqs
run: |
git clone --depth=1 https://github.com/open-quantum-safe/liboqs.git .ci/liboqs
git init .ci/liboqs
git -C .ci/liboqs remote add origin https://github.com/open-quantum-safe/liboqs.git
# liboqs 0.15.0
git -C .ci/liboqs fetch --depth=1 origin 97f6b86b1b6d109cfd43cf276ae39c2e776aed80
git -C .ci/liboqs checkout --detach 97f6b86b1b6d109cfd43cf276ae39c2e776aed80
cmake -S .ci/liboqs -B .ci/liboqs/build \
-GNinja \
-DBUILD_SHARED_LIBS=ON \
Expand Down
55 changes: 47 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
workflow_dispatch:

permissions:
contents: write
contents: read

jobs:
build-release-artifacts:
Expand Down Expand Up @@ -49,6 +49,9 @@ jobs:
- name: Install frontend dependencies
run: npm ci

- name: Audit web dependencies
run: npm audit --audit-level=high

- name: Build frontend
run: npm run build

Expand All @@ -74,14 +77,26 @@ jobs:
- name: Install built wheel
run: |
python -m venv /tmp/qe-release-test
. /tmp/qe-release-test/bin/activate
python -m pip install dist/*.whl
quantum-encryptor-agent health --json
/tmp/qe-release-test/bin/python -m pip install dist/*.whl
cd "$(mktemp -d)"
/tmp/qe-release-test/bin/quantum-encryptor-agent health --json
/tmp/qe-release-test/bin/python -I - <<'PY'
from pathlib import Path
import sys

import api_app
import pqc_agent_tools

prefix = Path(sys.prefix).resolve()
for module in (api_app, pqc_agent_tools):
module_path = Path(module.__file__).resolve()
assert module_path.is_relative_to(prefix), (module.__name__, module_path, prefix)
PY

- name: Smoke installed web UI
run: |
. /tmp/qe-release-test/bin/activate
PORT=4002 python -m api_app &
cd "$(mktemp -d)"
PORT=4002 /tmp/qe-release-test/bin/python -I -m api_app &
server_pid=$!
trap 'kill "$server_pid"' EXIT
for attempt in $(seq 1 30); do
Expand All @@ -104,8 +119,28 @@ jobs:
name: quantum-encryptor-dist
path: dist/*

publish-release:
name: Publish GitHub release
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
needs: build-release-artifacts
permissions:
actions: read
contents: write

steps:
- name: Check out release metadata
uses: actions/checkout@v6
with:
persist-credentials: false

- name: Download verified artifacts
uses: actions/download-artifact@v5
with:
name: quantum-encryptor-dist
path: dist

- name: Publish GitHub release
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: >
Expand Down Expand Up @@ -140,7 +175,11 @@ jobs:

- name: Build and install native liboqs
run: |
git clone --depth=1 https://github.com/open-quantum-safe/liboqs.git .ci/liboqs
git init .ci/liboqs
git -C .ci/liboqs remote add origin https://github.com/open-quantum-safe/liboqs.git
# liboqs 0.15.0
git -C .ci/liboqs fetch --depth=1 origin 97f6b86b1b6d109cfd43cf276ae39c2e776aed80
git -C .ci/liboqs checkout --detach 97f6b86b1b6d109cfd43cf276ae39c2e776aed80
cmake -S .ci/liboqs -B .ci/liboqs/build \
-GNinja \
-DBUILD_SHARED_LIBS=ON \
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,12 @@ The CLI prints JSON only and never includes plaintext, private keys, passwords,
- Private keys must be password protected with scrypt-derived AES-256-GCM keys; unencrypted private keys and legacy encrypted private-key PEM metadata are rejected by default
- Private-key passwords require at least 16 characters, at least 5 unique characters, and must not match known weak values
- Decryption checks encrypted-file KEM metadata against the private-key KEM metadata, with `ML-KEM-768` and `Kyber768` treated as compatibility aliases
- PEM/key reads are capped at 128 KiB before parsing; plaintext and encrypted-container reads keep the existing bounded in-memory limits
- PEM/key reads are capped at 128 KiB before parsing; POSIX workspace inputs use descriptor-anchored, no-follow reads, and reads remain bounded even if a file changes during the operation
- The web UI enforces a 100 MiB plaintext processing limit because files are handled in memory; encrypted containers allow bounded header and authentication overhead above that plaintext limit
- State-changing local web API requests require a per-process API token and reject non-local browser origins when an `Origin` header is present
- The local agent CLI accepts only workspace-relative paths, returns machine-readable JSON without secret material, and writes private keys plus decrypted outputs with owner-only permissions on POSIX systems; non-overwrite output creation uses exclusive file creation
- Native `liboqs` is loaded lazily and missing backend support disables key generation/encryption instead of crashing the app
- CI runs Python formatting, linting, type checks, unit tests, custom web UI build/type checks, browser UI smoke, locked runtime install, and a native `liboqs` integration test job
- CI runs Python formatting, linting, type checks, unit tests, custom web UI build/type checks, API client tests, browser UI smoke, isolated installed-wheel checks, Python/npm dependency audits, locked runtime install, and a native `liboqs` integration test job pinned to the matching 0.15.0 release commit; repository CodeQL default setup provides static analysis
- See [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) for repository trust boundaries, assets, abuse cases, and invariants
- **Disclaimer**: This software has not undergone an independent security audit and should be reviewed before production use

Expand Down
8 changes: 6 additions & 2 deletions api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,12 @@ def _safe_unexpected(operation: str, exc: Exception) -> JSONResponse:


def _content_disposition(filename: str) -> str:
quoted = quote(filename)
return f"attachment; filename=\"{filename}\"; filename*=UTF-8''{quoted}"
ascii_fallback = "".join(
character if character.isascii() and 0x20 <= ord(character) < 0x7F else "_" for character in filename
)
ascii_fallback = ascii_fallback.replace("\\", "_").replace('"', "_") or "download.bin"
quoted = quote(filename, safe="")
return f"attachment; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quoted}"


def _download_response(data: bytes, filename: str, media_type: str = "application/octet-stream") -> Response:
Expand Down
6 changes: 4 additions & 2 deletions crypto_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -895,8 +895,10 @@ def _parse_encrypted_file_parts(encrypted_blob: bytes) -> EncryptedFileParts:

header_aad = encrypted_blob[: input_buffer.tell()]
encrypted_data_aes = input_buffer.read()
if not encrypted_data_aes:
raise FileFormatError("Missing AES-GCM ciphertext and authentication tag.")
if len(encrypted_data_aes) < cfg.AES_TAG_BYTES:
raise FileFormatError("AES-GCM payload is shorter than the authentication tag.")
if len(encrypted_data_aes) > cfg.MAX_FILE_BYTES + cfg.AES_TAG_BYTES:
raise SizeLimitError("AES-GCM payload exceeds maximum supported size.")

metadata = EncryptedFileMetadata(
version=version,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"scripts": {
"dev": "vite --host 127.0.0.1 --port 4001",
"build": "tsc --noEmit && vite build",
"check": "tsc --noEmit",
"check": "tsc --noEmit && npm run test:api",
"test:api": "node --test scripts/api_client.test.mjs",
"ui-smoke": "node scripts/ui_smoke.mjs"
},
"dependencies": {
Expand Down
104 changes: 78 additions & 26 deletions pqc_agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import io
import json
import os
import stat
import sys
import tempfile
from dataclasses import dataclass
Expand Down Expand Up @@ -103,10 +104,17 @@ def _reject_unsafe_path_text(path_text: str) -> Path:

def _resolve_input_path(path_text: str, workspace: Path) -> Path:
relative_path = _reject_unsafe_path_text(path_text)
resolved = (workspace / relative_path).resolve(strict=True)
try:
resolved = (workspace / relative_path).resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise AgentCommandError("invalid_path", "Input path could not be resolved.", EXIT_INVALID_INPUT) from exc
if not _is_relative_to(resolved, workspace):
raise AgentCommandError("path_outside_workspace", "Path escapes the workspace.", EXIT_PATH_VIOLATION)
if not resolved.is_file():
try:
is_file = resolved.is_file()
except OSError as exc:
raise AgentCommandError("invalid_path", "Could not inspect input path.", EXIT_INVALID_INPUT) from exc
if not is_file:
raise AgentCommandError("invalid_path", "Input path must be a file.", EXIT_INVALID_INPUT)
return resolved

Expand Down Expand Up @@ -222,43 +230,87 @@ def _atomic_write_file(path: Path, data: bytes, overwrite: bool, private_file: b
) from exc


def _read_workspace_file_limited(path_text: str, workspace: Path, max_bytes: int) -> tuple[Path, bytes]:
path = _resolve_input_path(path_text, workspace)
def _open_workspace_input(path: Path, workspace: Path) -> int:
"""Open a resolved workspace file without following replacement symlinks."""
if os.name == "nt":
return os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0))

relative_path = path.relative_to(workspace)
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0)
nofollow = getattr(os, "O_NOFOLLOW", 0)
directory_fd = os.open(workspace, directory_flags)
try:
size = path.stat().st_size
except OSError as exc:
raise AgentCommandError("invalid_path", "Could not stat input path.", EXIT_INVALID_INPUT) from exc
if size > max_bytes:
raise AgentCommandError(
"file_too_large",
"Input file exceeds the configured size limit.",
EXIT_INVALID_INPUT,
for part in relative_path.parts[:-1]:
next_fd = os.open(part, directory_flags | nofollow, dir_fd=directory_fd)
os.close(directory_fd)
directory_fd = next_fd
return os.open(
relative_path.name,
os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0) | nofollow,
dir_fd=directory_fd,
)
finally:
os.close(directory_fd)


def _read_resolved_workspace_file_limited(
path: Path,
workspace: Path,
max_bytes: int,
too_large_message: str,
) -> bytes:
fd: Optional[int] = None
try:
return path, path.read_bytes()
fd = _open_workspace_input(path, workspace)
file_stat = os.fstat(fd)
if not stat.S_ISREG(file_stat.st_mode):
raise AgentCommandError("invalid_path", "Input path must be a file.", EXIT_INVALID_INPUT)
if file_stat.st_size > max_bytes:
raise AgentCommandError("file_too_large", too_large_message, EXIT_INVALID_INPUT)
with os.fdopen(fd, "rb") as input_file:
fd = None
data = input_file.read(max_bytes + 1)
except AgentCommandError:
raise
except OSError as exc:
raise AgentCommandError("invalid_path", "Could not read input path.", EXIT_INVALID_INPUT) from exc
finally:
if fd is not None:
os.close(fd)

if len(data) > max_bytes:
raise AgentCommandError("file_too_large", too_large_message, EXIT_INVALID_INPUT)
return data


def _read_workspace_file_limited(path_text: str, workspace: Path, max_bytes: int) -> tuple[Path, bytes]:
path = _resolve_input_path(path_text, workspace)
data = _read_resolved_workspace_file_limited(
path,
workspace,
max_bytes,
"Input file exceeds the configured size limit.",
)
return path, data


def _read_workspace_text(path_text: str, workspace: Path, max_bytes: Optional[int] = None) -> tuple[Path, str]:
path = _resolve_input_path(path_text, workspace)
limit = cfg.MAX_PEM_BYTES if max_bytes is None else max_bytes
data = _read_resolved_workspace_file_limited(
path,
workspace,
limit,
"Text input file exceeds the configured size limit.",
)
try:
size = path.stat().st_size
except OSError as exc:
raise AgentCommandError("invalid_path", "Could not stat input path.", EXIT_INVALID_INPUT) from exc
if size > limit:
return path, data.decode("utf-8")
except UnicodeDecodeError as exc:
raise AgentCommandError(
"file_too_large",
"Text input file exceeds the configured size limit.",
"invalid_input",
"File must be valid UTF-8 text.",
EXIT_INVALID_INPUT,
)
try:
return path, path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise AgentCommandError("invalid_input", "File must be valid UTF-8 text.", EXIT_INVALID_INPUT) from exc
except OSError as exc:
raise AgentCommandError("invalid_path", "Could not read input path.", EXIT_INVALID_INPUT) from exc
) from exc


def _write_workspace_file(
Expand Down
Loading