This document describes the system design: module responsibilities, data flow, the layered architecture, and the reasoning behind key decisions. It is intended for anyone (including your future self, in interviews) to be able to explain how Chronolock works and why it is built this way.
Chronolock is a strict layered library. Dependencies flow downward only; no higher layer is ever imported by a lower one.
┌──────────────────────────────────────────────────────────────┐
│ CLI (cli.py + ui.py) │
│ argparse · rendering · error→exit-code · progress display │
└───────────────────────────────┬──────────────────────────────┘
│ composes
┌───────────────────────────────▼──────────────────────────────┐
│ Orchestration (vault.py) │
│ bury · open_vault · inspect · list_vaults · vault_status │
└──────────────┬───────────────────────────────┬───────────────┘
│ │
┌──────────────▼────────────┐ ┌────────────▼───────────────┐
│ Puzzle (puzzle.py) │ │ Crypto (crypto.py) │
│ hash-chain solver │ │ HKDF · AES-GCM · HMAC │
│ difficulty validation │ │ size validation · wipe │
└──────────────┬────────────┘ └────────────┬───────────────┘
│ feeds the key into │
└────────────────────────────────┘
│ both produce/consume
┌──────────────────────────────▼──────────────────────────────┐
│ Persistence (store.py) │
│ container format · atomic IO · store dir · id generation │
└──────────────────────────────┬──────────────────────────────┘
│ imports only
┌──────────────────────────────▼──────────────────────────────┐
│ Support (model.py · errors.py) │
│ dataclasses & value objects · exception hierarchy │
└──────────────────────────────────────────────────────────────┘
cli.pymay importvault,store,difficulty,ui,errors.vault.pymay importpuzzle,crypto,store,model,errors.store.pymay importcrypto(forEncryptedSecrettyping &compute_hmac),model,errors.puzzle.py/crypto.pyimport onlymodel-adjacent types + stdlib.ui.pyimportsmodelonly (pure rendering).- Never:
storeimportsvault;puzzleimportscrypto; any module importsui.
Violating these rules is a design smell and a code-review blocker.
| Module | Responsibility | Key public API |
|---|---|---|
model.py |
Pure data: statuses, metadata, vault, unlock result | VaultStatus, VaultMetadata, Vault, UnlockResult |
errors.py |
Exception hierarchy (single base ChronolockError) |
ChronolockError + subclasses |
puzzle.py |
Time-lock primitive: sequential SHA-256 chain | PuzzleSpec, generate_seed, solve, validate_difficulty, estimate_seconds |
crypto.py |
Key derivation, sealing, verification, wiping | derive_keys, encrypt, decrypt, compute_hmac, secure_wipe |
store.py |
On-disk vault container + atomic IO + store dir | VaultStore, LoadedVault, default_store_path, generate_vault_id |
vault.py |
Orchestration of bury/open/status/list | bury, open_vault, inspect, list_vaults, vault_status |
difficulty.py |
Hash-rate measurement & duration→difficulty | measure_hash_rate, seconds_to_difficulty, format_estimate |
ui.py |
Terminal rendering (ANSI, progress, tables) | enable_colour, render_*, _banner |
cli.py |
Composition root: args, dispatch, error mapping | build_parser, main, cmd_* |
user → cli.cmd_bury
→ vault.bury(store, options, progress)
│ 1. validate options
│ 2. difficulty = explicit ? explicit : seconds_to_difficulty(desired, hash_rate)
│ 3. seed = puzzle.generate_seed() (CSPRNG)
│ 4. solution = puzzle.solve(PuzzleSpec(seed, d)) (runs the chain once)
│ 5. sealed = crypto.encrypt(secret, solution.final_hash)
│ 6. metadata = VaultMetadata(...)
│ 7. store.save(metadata, sealed) (atomic JSON write)
└─ returns Vault(metadata, secret) ← plaintext only in memory
→ cli prints render_bury_summary(...) ← never the secret
user → cli.cmd_open
→ vault.open_vault(store, id, progress)
│ 1. loaded = store.load(id) (metadata + encrypted)
│ 2. spec = PuzzleSpec(seed, difficulty)
│ 3. solution = puzzle.solve(spec, progress) ← 🔥 THE DELAY 🔥
│ 4. plaintext = crypto.decrypt(loaded.encrypted, solution.final_hash)
└─ returns UnlockResult(vault, iterations, elapsed)
→ cli prints header + stats; secret only if --reveal
The essential insight: step 3 is the lock. The key simply does not exist until the chain has been fully re-computed. There is nothing to steal, no flag to flip — only math stands between you and the secret.
A timer is a suggestion; it lives in software an attacker can edit, or on a clock an attacker can change. A hash chain is a physical fact: you either computed the iterations or you didn't. This is the same class of construction as the classic Rivest–Shamir–Wagner (1996) time-lock puzzle, applied to a pure proof-of-work chain. (Our chain is intentionally simpler than RSW's modular-exponentiation construction — it trades memory/space for the clean, inspectable property that every hash is required. The trade-off: no algebraic shortcut exists for SHA-256, whereas RSA-modular-exponent puzzles do have a theoretical shortcut for the puzzle designer. For our threat model — no trusted setup — the hash chain is the honest choice.)
- HKDF turns the 256-bit puzzle output into two independent, labelled
keys. Deterministic by construction (same
final→ same keys). - AES-GCM is authenticated encryption: it both encrypts and detects tampering in one primitive.
- HMAC-SHA256 (on
nonce||ciphertext||tag) gives us a cheap fast-fail integrity check before we spend the cost of an AES decrypt — and because the MAC key is derived from the puzzle, a wrong seed fails here too, without needing the decrypt to round-trip.
- Human-inspectable (you can
cata vault and see exactly what is public). - Trivially versionable (
format+versionenvelope). - Zero dependencies beyond stdlib
json/base64. - Base64 overhead (~33%) is irrelevant for ≤256 KiB secrets.
A crashed save must never leave a truncated .vault. Temp-then-os.replace
guarantees the final path is either the old complete file or the new complete
file — never a half-written one.
Testability and rigour. All the pretty formatting lives in pure functions in
ui.py that take data and return strings — unit-testable without a terminal
or a subprocess. cli.py stays thin: parse args, call the library, render,
map errors.
pyproject.toml sets disallow_untyped_defs = true for mypy. Every public
function is annotated. This is a self-imposed standard — it makes the codebase
readable, self-documenting and interview-safe, and make typecheck becomes a
hard gate.
puzzle.solveis synchronous and blocking by design; there is no value in a background thread because the work is sequential.- Progress is reported via callbacks, not by peeking at a running thread. The CLI renders a progress bar by redrawing on each callback.
- The vault store has no locking; the contract is single-writer per
process. (Multi-process access is a future
ROADMAPitem.) measure_hash_ratecaches its result in a module variable (machines don't change speed mid-process); it is not thread-safe but the CLI is single- threaded.
- Every library layer raises typed exceptions from
errors.py(single baseChronolockError). cli.mainwraps dispatch in atry/except ChronolockErrorand funnels through_handle_errors, which maps type → friendly stderr message + exit code.store.list_metadatadeliberately swallows per-vault parse errors (skips corrupt vaults) rather than aborting a listing — the batch operation never thrashes the whole list because one file is bad. (A--verbosemode surfaces them.)
Default store locations (see store.default_store_path):
| OS | Path |
|---|---|
| Linux | ~/.local/share/chronolock/ |
| macOS | ~/Library/Application Support/chronolock/ |
| Windows | %APPDATA%\chronolock\ |
| Override | $CHRONOLOCK_DIR or --dir <path> |
Inside a store, each vault is one file: <vault_id>.vault.
Two tiers, enforced by pytest markers (--strict-markers):
| Tier | Marker | Scope | Examples |
|---|---|---|---|
| Unit | @pytest.mark.unit (default) |
A single module, no real IO | derive_keys determinism, solve determinism, format_estimate |
| Integration | @pytest.mark.integration |
Full stack, real crypto + disk | tests/test_integration.py: bury→open round-trip, tampering detection |
Fixtures are centralized in tests/conftest.py:
vault_store→ fresh temp dir per test.entropy_source/tiny_puzzle_spec→ deterministic, fast puzzle inputs.sample_metadata,sealed_secret→ valid data objects independent of unimplemented code.
The integration tests are your definition of done — when they pass, the tool genuinely works.
If you ever need a breaking change to the container:
- Bump
FORMAT_VERSIONto2. - Keep
version: 1read support (load both). - Add a migration path in
store.load(or avault migratecommand). - Never silently rewrite an old vault.
Keeping old-format reads working is a hard requirement; silently changing the format would orphan every existing vault.
See SPEC.md §2. Additionally:
- HKDF — HMAC-based key derivation function (RFC 5869).
- AES-GCM — Galois/Counter Mode; authenticated encryption.
- RSW time-lock — Rivest, Shamir & Wagner's 1996 time-lock puzzle construction using modular squaring.