Type-safe, API-first plausibly deniable multi-secret encryption containers
Project description
Keyprism
One container. Different truths for different keys.
Keyprism is a typed, API-first Python library for fixed-size, plausibly deniable multi-secret containers. A single container can hold several complete plaintexts. Each plaintext is independently protected by its own passphrase, while every unwritten slot is replaced with cryptographically secure random bytes before the container is saved.
Security status: Keyprism 0.1.0 is pre-audit software. It is suitable for experimentation and review, but it should not protect high-risk production data until the design and implementation receive independent cryptographic review.
Contents
- What Keyprism provides
- Installation
- Quick start
- Container lifecycle
- Public API
- Binary format
- Cryptographic profile
- Deniability and information bounds
- Error handling
- Integration example
- Security boundaries
- Project structure
- Development and verification
- Building and publishing
- Release checklist
What Keyprism provides
| Capability | Behavior |
|---|---|
| Multiple secrets | Each passphrase decrypts one complete, independently encrypted plaintext |
| Fixed-size slots | Every physical slot has exactly the configured byte capacity |
| Decoy slots | Unwritten slots are filled with Python's CSPRNG |
| Length hiding | Plaintexts are padded to the fixed encrypted payload boundary |
| Misuse-resistant API | Callers cannot provide nonces, salts, padding, offsets, or raw slot indexes |
| Authenticated encryption | ChaCha20-Poly1305 rejects altered or incorrectly keyed slots |
| Password hardening | A unique 16-byte salt and Argon2id are used for every real slot |
| Uniform opening work | Every physical slot is attempted; opening never returns early after a match |
| Static typing | Full annotations and a PEP 561 marker support strict type checking |
| Minimal runtime surface | Only argon2-cffi and cryptography are runtime dependencies |
The precise security claim is:
Given the container alone, an adversary cannot distinguish a real encrypted slot from a randomly-filled empty slot with better than negligible advantage over guessing.
The number of physical slots is observable from the file size. Keyprism hides which physical slots are real and therefore how many real secrets exist; it does not hide the physical upper bound.
Installation
After the package is published on PyPI:
python -m pip install keyprism
From a source checkout:
python -m pip install .
For development, testing, packaging, and documentation:
python -m pip install -e ".[dev,docs]"
Keyprism requires Python 3.11 or newer.
Quick start
from pathlib import Path
from keyprism import Container
path = Path("private-notes.kpr")
# slot_capacity is the total serialized size of each physical slot.
container = Container.create(num_slots=4, slot_capacity=4096)
# Slot placement is selected internally and is not exposed to the caller.
container.write_slot("ordinary-passphrase", b"Buy milk")
container.write_slot("private-passphrase", b"Recovery instructions")
# save() automatically random-fills the two remaining slots.
container.save(path)
loaded = Container.load(path)
assert loaded.open("ordinary-passphrase") == b"Buy milk"
assert loaded.open("private-passphrase") == b"Recovery instructions"
assert loaded.open("unknown-passphrase") is None
Secrets are accepted and returned as bytes. Keyprism does not guess text encodings, serialize Python objects, log plaintext, or manage user accounts. Those responsibilities belong to the application using the library.
Container lifecycle
Container.create()
|
v
writable in-memory container
|
+---- write_slot(passphrase, plaintext) [repeat as needed]
|
+---- open(passphrase) [optional in-memory check]
|
v
finalize() or save()
|
v
all unwritten slots receive CSPRNG bytes
|
v
read-only finalized container
|
+---- save(path)
+---- open(passphrase)
|
v
Container.load(path) -> read-only container
A loaded container cannot accept new secrets. The on-disk format deliberately contains no marker saying which slots are unused, so safely selecting an overwrite target after loading is impossible. Create a new container when the set of secrets must change.
Calling save() automatically calls finalize(). Calling finalize() more than once is safe and has no additional effect.
Public API
Creating a container
container = Container.create(num_slots=4, slot_capacity=4096)
| Parameter | Meaning | Constraint |
|---|---|---|
| num_slots | Number of physical slots | Integer greater than zero |
| slot_capacity | Total serialized bytes per slot | At least 48 bytes |
Useful read-only properties:
| Property | Result |
|---|---|
| container.num_slots | Observable physical slot count |
| container.slot_capacity | Serialized bytes per physical slot |
| container.max_plaintext_size | Maximum plaintext bytes per slot |
| container.serialized_size | Exact saved file size |
| container.finalized | Whether every slot has serialized bytes |
Writing a secret
container.write_slot(passphrase: str, plaintext: bytes) -> None
Keyprism validates the inputs, selects a random unwritten slot, creates a fresh salt and nonce, pads the plaintext, derives the key, and encrypts the fixed payload. The public API exposes no unsafe cryptographic controls.
Finalizing and saving
container.finalize() -> None
container.save(path: str | Path) -> None
Finalization fills every unwritten slot with output from secrets.token_bytes(). Saving writes only the format header and fixed-size slot bytes. On POSIX systems, newly created files request mode 0600.
Loading and opening
container = Container.load(path)
plaintext = container.open(passphrase)
open() returns matching plaintext bytes or None. A wrong passphrase and the absence of a matching slot deliberately share the same result. The method attempts authentication against every physical slot and does not return early.
Binary format
Keyprism format version 1 starts with an eight-byte framing header.
Header
| Offset | Size | Field |
|---|---|---|
| 0 | 4 bytes | Magic and format version: KPR\x01 |
| 4 | 4 bytes | Big-endian slot capacity |
| 8 | Remaining bytes | One or more fixed-size slots |
Slot count is not stored as a field. It is derived as:
physical_slot_count = (file_size - 8) / slot_capacity
Physical slot
Every slot is exactly slot_capacity bytes:
| Slot offset | Size | Real slot | Empty slot |
|---|---|---|---|
| 0 | 16 bytes | Random Argon2id salt | CSPRNG bytes |
| 16 | 12 bytes | Random AEAD nonce | CSPRNG bytes |
| 28 | slot_capacity - 44 bytes | Encrypted fixed payload | CSPRNG bytes |
| slot_capacity - 16 | 16 bytes | Poly1305 tag | CSPRNG bytes |
Before encryption, the fixed payload contains:
| Payload field | Size |
|---|---|
| Plaintext length | 4 bytes |
| Plaintext | Variable |
| Random length-hiding padding | Remaining payload bytes |
Therefore:
max_plaintext_size = slot_capacity - 48
serialized_file_size = 8 + (num_slots * slot_capacity)
For the default 4096-byte slot:
| Measurement | Bytes |
|---|---|
| Total slot capacity | 4096 |
| Salt + nonce + tag + encrypted length field | 48 |
| Maximum plaintext | 4048 |
Cryptographic profile
Format version 1 fixes the following profile:
| Component | Selection |
|---|---|
| KDF | Argon2id version 1.3 |
| KDF time cost | 3 iterations |
| KDF memory cost | 131,072 KiB (128 MiB) |
| KDF parallelism | 1 lane |
| Derived key | 32 bytes |
| Salt | 16 random bytes per real slot |
| AEAD | ChaCha20-Poly1305 |
| Nonce | 12 random bytes per real slot |
| Authentication tag | Full 16-byte Poly1305 tag |
| Randomness | Python secrets module |
The version and slot capacity are bound into AEAD associated data. KDF values are format-version parameters rather than per-container metadata; changing them silently would make existing version-1 containers unreadable.
The default KDF measured approximately 0.45 seconds per derivation on the development machine used for the 0.1.0 verification. Performance varies by hardware.
Deniability and information bounds
A real slot consists of independently random salt and nonce bytes followed by ChaCha20-Poly1305 output. An empty slot consists entirely of CSPRNG output. Both occupy exactly the same number of bytes.
Automated regression tests compare real and empty slot byte distributions using chi-square statistics, Shannon entropy, and total-variation distance. These tests can detect implementation regressions; they are not a substitute for a cryptographic proof or independent review.
An observer can still learn:
- that a Keyprism-format file probably exists when the header is visible;
- the slot capacity;
- the number of physical slots;
- the total file size;
- filesystem, backup, transport, and application metadata outside the format.
Read THREAT_MODEL.md for the complete claim and exclusions.
Error handling
Typed exceptions are available from keyprism.errors:
| Exception | Meaning |
|---|---|
| KeyPrismError | Base class for package-specific errors |
| ConfigurationError | Invalid container dimensions |
| ContainerFormatError | Malformed or unsupported serialized framing |
| ContainerStateError | Operation is invalid for the current lifecycle state |
| InvalidPassphraseError | Passphrase is empty or unusable |
| NoAvailableSlotError | Every writable slot has already been assigned |
| PaddingError | Authenticated internal payload framing is invalid |
| PlaintextTooLargeError | Plaintext exceeds container.max_plaintext_size |
Exception messages are static and never interpolate passphrases, derived keys, plaintext, or raw slot bytes.
from keyprism import Container, PlaintextTooLargeError
container = Container.create(slot_capacity=128)
try:
container.write_slot("passphrase", b"x" * 100)
except PlaintextTooLargeError:
# Handle the condition without logging the plaintext or passphrase.
pass
Integration example
The repository includes examples/secure_notes_demo.py, a small third-party-style application that imports only the public API.
python examples/secure_notes_demo.py create notes.kpr
python examples/secure_notes_demo.py open notes.kpr
Applications should:
- collect passphrases without echoing them;
- keep plaintext in memory only as long as necessary;
- avoid logs, telemetry, crash reports, and shell history containing secrets;
- choose a slot capacity that hides expected plaintext-length differences;
- store or transmit the finalized container as an opaque byte file.
Security boundaries
Keyprism is designed for an adversary who possesses the container and possibly one disclosed passphrase. It does not protect against:
- live memory forensics on an unlocked process;
- a compromised host OS, Python runtime, dependency, or random-number source;
- keylogging, screen capture, swap, or hibernation capture;
- an adversary with independent evidence of exact real-secret count;
- coercion that obtains every passphrase;
- weak, reused, or externally disclosed passphrases;
- denial of service, file deletion, corruption, or rollback;
- metadata created outside the container.
Python cannot promise cycle-level constant-time execution. Keyprism provides constant relative work across slot positions: every physical slot performs the same KDF and authentication attempt. See SECURITY.md for reporting and operational guidance.
Project structure
The repository follows the standard source-layout packaging structure used in the referenced PyPI publishing walkthrough:
keyprism/
├── src/
│ └── keyprism/
│ ├── __init__.py
│ ├── container.py
│ ├── crypto.py
│ ├── errors.py
│ └── py.typed
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_api_misuse.py
│ ├── test_container.py
│ ├── test_crypto.py
│ ├── test_errors.py
│ ├── test_indistinguishability.py
│ └── test_timing.py
├── docs/
│ ├── api.rst
│ ├── conf.py
│ ├── format.rst
│ └── index.rst
├── examples/
│ └── secure_notes_demo.py
├── .github/workflows/ci.yml
├── CHANGELOG.md
├── LICENSE
├── MANIFEST.in
├── MIGRATION.md
├── pyproject.toml
├── README.md
├── SECURITY.md
└── THREAT_MODEL.md
The installable distribution contains the keyprism package under src/. Tests, documentation, examples, and release files are included in the source archive but not in the runtime wheel.
Development and verification
Install all contributor tools:
python -m pip install -e ".[dev,docs]"
Run the same gates as CI:
ruff check .
ruff format --check .
mypy --strict src tests examples
pytest -v --tb=short
pytest --cov=keyprism --cov-report=term-missing
sphinx-build -W -b html docs docs/_build/html
python -m build
python -m twine check dist/*
The current suite covers:
- Argon2id cross-implementation known-answer behavior;
- the RFC 8439 ChaCha20-Poly1305 test vector;
- empty, maximum-size, and boundary plaintexts;
- wrong passphrases and ciphertext tampering;
- malformed file framing;
- public API misuse resistance;
- real-versus-decoy distribution checks;
- runtime correlation with matching slot position;
- save/load and third-party integration behavior.
The required coverage floor is 90% across the complete keyprism package.
Building and publishing
The packaging workflow follows the build/check/upload sequence demonstrated in the referenced video.
1. Build clean distributions
python -m build
Expected artifacts:
dist/keyprism-0.1.0-py3-none-any.whl
dist/keyprism-0.1.0.tar.gz
2. Validate metadata and README rendering
python -m twine check dist/*
3. Test the release on TestPyPI
python -m twine upload --repository testpypi \
dist/keyprism-0.1.0-py3-none-any.whl \
dist/keyprism-0.1.0.tar.gz
# In a fresh environment, install runtime dependencies from PyPI first.
python -m pip install "argon2-cffi>=23.1.0" "cryptography>=42.0.0"
python -m pip install --index-url https://test.pypi.org/simple/ \
--no-deps keyprism==0.1.0
python -c "import keyprism; print(keyprism.__version__)"
4. Publish to PyPI
python -m twine upload \
dist/keyprism-0.1.0-py3-none-any.whl \
dist/keyprism-0.1.0.tar.gz
Use a scoped PyPI API token or PyPI Trusted Publishing. Never place tokens in source files, command history, README examples, CI logs, or chat messages.
Release checklist
Before publishing 0.1.0:
- Runtime package uses the src/keyprism layout.
- The public API is fully typed and includes py.typed.
- Ruff, strict mypy, tests, coverage, docs, build, and Twine checks pass.
- Runtime dependencies are limited to argon2-cffi and cryptography.
- Rewrite Git history to purge legacy key and log files.
- Run gitleaks against the full rewritten history.
- Configure a scoped PyPI token or Trusted Publishing.
- Upload and smoke-test on TestPyPI.
- Upload version 0.1.0 to PyPI and create the Git tag.
Before a 1.0 release:
- Obtain independent review of THREAT_MODEL.md and src/keyprism/crypto.py.
- Address every security-review finding.
- Document format stability and compatibility commitments.
Migration and compatibility
The original Tkinter prototype and its ciphertext/decoy format are not compatible with Keyprism. See MIGRATION.md before publishing the repository.
The earlier unpublished local package name used a different format magic and AEAD domain. Recreate any experimental containers with Keyprism 0.1.0; do not rename old container files and assume compatibility.
License
Keyprism is distributed under the MIT License.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file keyprism-0.1.0.tar.gz.
File metadata
- Download URL: keyprism-0.1.0.tar.gz
- Upload date:
- Size: 29.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ca47a7178e5b78a7d46ef093f7bdbf20e63aa3da55a132c9feb93ac3075d76cc
|
|
| MD5 |
83669a629114bfe6f2ab67e40f1fbd89
|
|
| BLAKE2b-256 |
cbb9d18a182d46bb996cdf3314ef542ebfa6ec6e10f23cc9a9a0eb515a2c8447
|
File details
Details for the file keyprism-0.1.0-py3-none-any.whl.
File metadata
- Download URL: keyprism-0.1.0-py3-none-any.whl
- Upload date:
- Size: 14.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a8622aa861b0c7a84d428638447c4a26dc5533fd51f26752197f00981d9f4684
|
|
| MD5 |
34a9a21ece0bca86601e27f888cfdafc
|
|
| BLAKE2b-256 |
837a09c913758cc16d39ac423c6e05884f141f2a333d8f4568abd344961a7c5b
|