Skip to content

Commit df08151

Browse files
committed
feat(security): key hardening + backup lifecycle (v0.11.0)
- P1: passphrase-wrapped keys via scrypt (Tier 1, opt-in CLOAK_PASSPHRASE) - _derive_wrapping_key(), _wrap_key(), _unwrap_key(), _detect_key_format() - CLOAKKEY1 magic header, 32-byte random salt, n=2^17 (128 MiB) - cloak key wrap/unwrap CLI commands - _verify_permissions() auto-corrects on every key access - P0: legacy plaintext backup migration - cloak backup migrate (encrypt, verify SHA-256, remove/quarantine) - SessionStart warns when legacy backups exist - P2: backup auto-prune - cloak backup prune --ttl 30d --keep-last 10 --apply - SessionStart prune hint when count > 20 - 46 new tests (354 total, 0 failures)
1 parent e850820 commit df08151

10 files changed

Lines changed: 1271 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.11.0] - 2026-02-25
11+
12+
### Security
13+
- **Passphrase-wrapped keys (Tier 1)**: Key files at `~/.cloakmcp/keys/` can now be encrypted
14+
at rest using a passphrase-derived wrapping key (scrypt, n=2^17, 128 MiB memory cost). Set
15+
`CLOAK_PASSPHRASE` env var to enable. Wrapping format uses `CLOAKKEY1` magic header for
16+
unambiguous format detection. Raw key (Tier 0) remains the default for backward compatibility
17+
- **Permission verification**: `_verify_permissions()` checks and auto-corrects file permissions
18+
on every key access. Logs a warning if permissions were wrong (indicates external tampering)
19+
20+
### Added
21+
- `cloak key wrap` — Wrap existing raw key with passphrase (Tier 0 → Tier 1)
22+
- `cloak key unwrap` — Unwrap key back to raw format (Tier 1 → Tier 0)
23+
- `cloak backup migrate` — Encrypt legacy plaintext backup directories into `.enc` files with
24+
integrity verification (decrypt → SHA-256 compare before deleting original). Supports
25+
`--dry-run` (default) and `--quarantine` modes
26+
- `cloak backup prune` — Remove old backups based on TTL and keep-last policy. Supports `--ttl`
27+
(default: 30d), `--keep-last` (default: 10), `--apply` (dry-run without), `--include-legacy`
28+
- `_derive_wrapping_key()`, `_wrap_key()`, `_unwrap_key()`, `_detect_key_format()` in
29+
`storage.py` — scrypt-based key wrapping primitives
30+
- `wrap_keyfile()`, `unwrap_keyfile()` — High-level key file wrapping/unwrapping with
31+
verify-before-write safety
32+
- `migrate_legacy_backup()`, `migrate_all_legacy_backups()` in `dirpack.py` — per-backup and
33+
batch migration of plaintext directories to encrypted tarballs
34+
- `prune_backups()`, `_parse_ttl()` in `dirpack.py` — TTL-based backup pruning with keep-last
35+
safety net
36+
- `CLOAK_PASSPHRASE` environment variable for Tier 1 key wrapping
37+
- SessionStart: warns when legacy plaintext backups exist in external store; emits prune hint
38+
when backup count exceeds 20
39+
- 46 new tests: `tests/test_key_wrapping.py` (25 tests), `tests/test_backup_lifecycle.py`
40+
(21 tests)
41+
1042
## [0.10.1] - 2026-02-25
1143

1244
### Fixed

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
1313
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
1414
[![PyPI](https://img.shields.io/pypi/v/cloakmcp.svg)](https://pypi.org/project/cloakmcp/)
15-
[![Version](https://img.shields.io/badge/version-0.10.1-orange.svg)](https://github.com/ovitrac/CloakMCP/releases)
16-
[![Tests](https://img.shields.io/badge/tests-308%20passing-brightgreen.svg)](./tests)
15+
[![Version](https://img.shields.io/badge/version-0.11.0-orange.svg)](https://github.com/ovitrac/CloakMCP/releases)
16+
[![Tests](https://img.shields.io/badge/tests-354%20passing-brightgreen.svg)](./tests)
1717
[![MCP](https://img.shields.io/badge/MCP-6%20tools-blueviolet.svg)](#mcp-tool-server--6-tools)
1818
[![DeepWiki](https://img.shields.io/badge/Docs-DeepWiki-purple.svg)](https://deepwiki.com/ovitrac/CloakMCP)
1919
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
@@ -222,6 +222,10 @@ cloak serve --transport sse --port 8766
222222
| `cloak status --dir DIR` | Session diagnostics: state, manifest, delta, vault, tags, backups, audit |
223223
| `cloak restore --dir DIR` | Restore secrets from vault (default) or `--from-backup --force` |
224224
| `cloak hook <event>` | Hook handler for Claude Code integration (session-start, session-end, guard-write, guard-read, prompt-guard, safety-guard, audit-log) |
225+
| `cloak key wrap` | Wrap key with passphrase (Tier 0 → Tier 1); requires `CLOAK_PASSPHRASE` |
226+
| `cloak key unwrap` | Unwrap key back to raw format (Tier 1 → Tier 0) |
227+
| `cloak backup migrate` | Encrypt legacy plaintext backups; `--dry-run`, `--quarantine` |
228+
| `cloak backup prune` | Remove old backups; `--ttl 30d`, `--keep-last 10`, `--apply`, `--include-legacy` |
225229
| `cloak serve` | FastMCP server: stdio (default), SSE, or streamable-http transport |
226230
| `cloak serve --check` | Validate MCP server configuration and exit |
227231
| `cloak --version` | Print CloakMCP version |
@@ -592,6 +596,7 @@ keys/
592596
| `CLOAK_AUDIT_TOOLS` | *(unset)* | Set to `1` to enable Tier 2 tool metadata logging (hashed file paths) |
593597
| `CLOAK_REPACK_ON_WRITE` | *(unset)* | Set to `1` to auto-repack files after Write/Edit tool calls. Adds latency per write. |
594598
| `CLOAK_FAIL_CLOSED` | *(unset)* | Set to `1` to deny writes and refuse sessions when no policy is found (recommended for regulated environments) |
599+
| `CLOAK_PASSPHRASE` | *(unset)* | Passphrase for Tier 1 key wrapping (scrypt). When set, new keys are wrapped and existing wrapped keys are unlocked automatically |
595600

596601
---
597602

cloakmcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.10.1'
1+
__version__ = '0.11.0'

cloakmcp/cli.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,38 @@ def main() -> None:
314314
s_restore.add_argument("--backup-id", default=None,
315315
help="Timestamp of specific backup to restore from")
316316

317+
# ── v0.11.0: key management ────────────────────────────────
318+
s_key = sub.add_parser("key", help="Key management (wrap/unwrap)")
319+
key_sub = s_key.add_subparsers(dest="key_cmd", required=True)
320+
321+
s_key_wrap = key_sub.add_parser("wrap", help="Wrap key with passphrase (Tier 0 -> Tier 1)")
322+
s_key_wrap.add_argument("--dir", default=".", help="Project root directory")
323+
324+
s_key_unwrap = key_sub.add_parser("unwrap", help="Unwrap key back to raw (Tier 1 -> Tier 0)")
325+
s_key_unwrap.add_argument("--dir", default=".", help="Project root directory")
326+
327+
# ── v0.11.0: backup management ──────────────────────────────
328+
s_backup = sub.add_parser("backup", help="Backup management (migrate, prune)")
329+
backup_sub = s_backup.add_subparsers(dest="backup_cmd", required=True)
330+
331+
s_backup_migrate = backup_sub.add_parser("migrate", help="Encrypt legacy plaintext backups")
332+
s_backup_migrate.add_argument("--dir", default=".", help="Project root directory")
333+
s_backup_migrate.add_argument("--dry-run", action="store_true",
334+
help="Preview only (default behavior)")
335+
s_backup_migrate.add_argument("--quarantine", action="store_true",
336+
help="Move legacy dirs to quarantine instead of deleting")
337+
338+
s_backup_prune = backup_sub.add_parser("prune", help="Remove old backups")
339+
s_backup_prune.add_argument("--dir", default=".", help="Project root directory")
340+
s_backup_prune.add_argument("--ttl", default="30d",
341+
help="TTL threshold (e.g. 30d, 24h, 90m) — default: 30d")
342+
s_backup_prune.add_argument("--keep-last", type=int, default=10,
343+
help="Always keep N newest backups (default: 10)")
344+
s_backup_prune.add_argument("--apply", action="store_true",
345+
help="Actually delete (dry-run without this flag)")
346+
s_backup_prune.add_argument("--include-legacy", action="store_true",
347+
help="Also prune legacy plaintext directories")
348+
317349
# ── serve (FastMCP) ────────────────────────────────────────
318350
s_serve = sub.add_parser("serve", help="Start the MCP server (FastMCP)")
319351
s_serve.add_argument("--policy", default=None, help="Path to YAML policy file")
@@ -645,6 +677,88 @@ def main() -> None:
645677
force=args.force, backup_id=args.backup_id)
646678
return
647679

680+
if args.cmd == "key":
681+
from .storage import wrap_keyfile, unwrap_keyfile, _project_slug, _key_path, _detect_key_format
682+
683+
if args.key_cmd == "wrap":
684+
_validate_dir_path(args.dir, "directory")
685+
try:
686+
path = wrap_keyfile(args.dir)
687+
print(f"Key wrapped: {path}", file=sys.stderr)
688+
print(" Format: CLOAKKEY1 (passphrase-wrapped via scrypt)", file=sys.stderr)
689+
except (RuntimeError, FileNotFoundError) as e:
690+
print(f"Error: {e}", file=sys.stderr)
691+
sys.exit(1)
692+
return
693+
694+
elif args.key_cmd == "unwrap":
695+
_validate_dir_path(args.dir, "directory")
696+
try:
697+
path = unwrap_keyfile(args.dir)
698+
print(f"Key unwrapped: {path}", file=sys.stderr)
699+
print(" Format: raw Fernet key (Tier 0)", file=sys.stderr)
700+
except (RuntimeError, FileNotFoundError) as e:
701+
print(f"Error: {e}", file=sys.stderr)
702+
sys.exit(1)
703+
except Exception as e:
704+
print(f"Error: wrong passphrase or corrupt key file: {e}", file=sys.stderr)
705+
sys.exit(1)
706+
return
707+
708+
if args.cmd == "backup":
709+
from .dirpack import migrate_all_legacy_backups, prune_backups
710+
711+
if args.backup_cmd == "migrate":
712+
_validate_dir_path(args.dir, "directory")
713+
dry_run = getattr(args, 'dry_run', True)
714+
quarantine = getattr(args, 'quarantine', False)
715+
results = migrate_all_legacy_backups(
716+
args.dir, dry_run=dry_run, quarantine=quarantine
717+
)
718+
if not results:
719+
print("No legacy plaintext backups found.", file=sys.stderr)
720+
return
721+
for r in results:
722+
status = r["status"]
723+
size_kb = r.get("size", 0) // 1024
724+
print(f" {r['timestamp']} [{status}] {size_kb} KB", file=sys.stderr)
725+
migrated = sum(1 for r in results if r["status"] == "migrated")
726+
failed = sum(1 for r in results if r["status"] == "failed")
727+
would = sum(1 for r in results if r["status"] == "would_migrate")
728+
if dry_run:
729+
print(f"\n[DRY RUN] Would migrate {would} backup(s).", file=sys.stderr)
730+
print("Run without --dry-run to execute.", file=sys.stderr)
731+
else:
732+
print(f"\nMigrated: {migrated}, Failed: {failed}", file=sys.stderr)
733+
return
734+
735+
elif args.backup_cmd == "prune":
736+
_validate_dir_path(args.dir, "directory")
737+
try:
738+
result = prune_backups(
739+
args.dir,
740+
ttl=args.ttl,
741+
keep_last=args.keep_last,
742+
apply=args.apply,
743+
include_legacy=args.include_legacy,
744+
)
745+
except ValueError as e:
746+
print(f"Error: {e}", file=sys.stderr)
747+
sys.exit(1)
748+
for d in result["details"]:
749+
size_kb = d.get("size", 0) // 1024
750+
print(f" {d['timestamp']} [{d['format']}] {size_kb} KB -> {d['action']}",
751+
file=sys.stderr)
752+
freed_kb = result["freed_bytes"] // 1024
753+
if args.apply:
754+
print(f"\nPruned: {result['pruned']}, Kept: {result['kept']}, "
755+
f"Freed: {freed_kb} KB", file=sys.stderr)
756+
else:
757+
print(f"\n[DRY RUN] Would prune: {result['pruned']}, Keep: {result['kept']}",
758+
file=sys.stderr)
759+
print("Add --apply to execute.", file=sys.stderr)
760+
return
761+
648762
if args.cmd == "serve":
649763
try:
650764
from .fastmcp_server import create_server, build_parser as mcp_parser

0 commit comments

Comments
 (0)