Skip to content

Commit 527fd3d

Browse files
author
Your Name
committed
Merge remote-tracking branch 'bright-vision/feat/session-encryption-upstream' into v0.100.2
2 parents e9decca + 951988e commit 527fd3d

12 files changed

Lines changed: 638 additions & 13 deletions

File tree

cecli/args.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,24 @@ def get_parser(default_config_files, git_root):
370370
" (default: False)"
371371
),
372372
)
373+
group.add_argument(
374+
"--session-encrypt",
375+
action=argparse.BooleanOptionalAction,
376+
default=False,
377+
help=(
378+
"Encrypt saved sessions on disk (AES-256-GCM). Requires CECLI_SESSION_KEY or"
379+
" --session-key-file (default: False)"
380+
),
381+
)
382+
group.add_argument(
383+
"--session-key-file",
384+
metavar="SESSION_KEY_FILE",
385+
default=None,
386+
help=(
387+
"File containing a urlsafe-base64 32-byte session encryption key"
388+
" (default: use CECLI_SESSION_KEY only)"
389+
),
390+
).complete = shtab.FILE
373391
group.add_argument(
374392
"--mcp-servers",
375393
metavar="MCP_CONFIG_JSON",

cecli/helpers/crypto.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Simplified AES-256-GCM encryption for cecli session files.
2+
3+
Key improvements over PR #533's session_crypto.py:
4+
1. Raw binary format instead of base64-encoded payloads
5+
(eliminates base64 import, padding logic, and ascii encode/decode)
6+
2. decrypt_session_bytes is single-purpose (encrypted data only)
7+
3. Callers check is_encrypted_payload() first, then decrypt
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
import os
14+
from pathlib import Path
15+
from typing import Any
16+
17+
MAGIC = b"CECLI_ENCRYPTED_SESSION_v1\n"
18+
KEY_ENV = "CECLI_SESSION_KEY"
19+
KEY_BYTES = 32
20+
21+
22+
class SessionCryptoError(Exception):
23+
"""Session encrypt/decrypt failed."""
24+
25+
26+
def is_encrypted_payload(data: bytes) -> bool:
27+
"""Check whether *data* starts with the magic header."""
28+
return data.startswith(MAGIC)
29+
30+
31+
def resolve_key(*, key_file: str | Path | None = None) -> bytes | None:
32+
"""Load a 32-byte key from CECLI_SESSION_KEY (urlsafe base64) or a key file.
33+
34+
Returns None when no key is configured or the value is invalid.
35+
"""
36+
raw = os.environ.get(KEY_ENV, "").strip()
37+
if raw:
38+
key = _decode_key_b64(raw)
39+
if key is not None:
40+
return key
41+
42+
if key_file:
43+
path = Path(key_file).expanduser()
44+
if path.is_file():
45+
key = _decode_key_b64(path.read_text(encoding="utf-8").strip())
46+
if key is not None:
47+
return key
48+
49+
return None
50+
51+
52+
def _decode_key_b64(text: str) -> bytes | None:
53+
"""Decode a urlsafe-base64 32-byte key, tolerating missing padding."""
54+
try:
55+
import base64
56+
57+
# Python's b64decode accepts excess padding, so "==" always works.
58+
key = base64.urlsafe_b64decode(text + "==")
59+
except (ValueError, UnicodeEncodeError):
60+
return None
61+
if len(key) != KEY_BYTES:
62+
return None
63+
return key
64+
65+
66+
def encrypt_session_dict(session_data: dict[str, Any], key: bytes) -> bytes:
67+
"""Encrypt *session_data* and return bytes ready to write to disk.
68+
69+
Format: CECLI_ENCRYPTED_SESSION_v1\n || 12-byte nonce || AES-256-GCM ciphertext
70+
"""
71+
if len(key) != KEY_BYTES:
72+
raise SessionCryptoError(f"Session key must be {KEY_BYTES} bytes.")
73+
74+
try:
75+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
76+
except ImportError as err:
77+
raise SessionCryptoError(
78+
"Session encryption requires the cryptography package" " (pip install cryptography)."
79+
) from err
80+
81+
plaintext = json.dumps(session_data, ensure_ascii=False).encode("utf-8")
82+
nonce = os.urandom(12)
83+
ciphertext = AESGCM(key).encrypt(nonce, plaintext, None)
84+
85+
return MAGIC + nonce + ciphertext
86+
87+
88+
def decrypt_session_bytes(data: bytes, key: bytes) -> dict[str, Any]:
89+
"""Decrypt a previously encrypted session blob.
90+
91+
Raises SessionCryptoError on any failure (wrong key, corrupted data,
92+
invalid format). Callers MUST check *is_encrypted_payload* first.
93+
"""
94+
if len(key) != KEY_BYTES:
95+
raise SessionCryptoError(f"Session key must be {KEY_BYTES} bytes.")
96+
97+
try:
98+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
99+
except ImportError as err:
100+
raise SessionCryptoError(
101+
"Session encryption requires the cryptography package" " (pip install cryptography)."
102+
) from err
103+
104+
body = data[len(MAGIC) :]
105+
if len(body) < 13:
106+
raise SessionCryptoError("Encrypted session payload is too short.")
107+
108+
nonce, ciphertext = body[:12], body[12:]
109+
110+
try:
111+
plaintext = AESGCM(key).decrypt(nonce, ciphertext, None)
112+
except Exception as err:
113+
raise SessionCryptoError(
114+
"Could not decrypt session (wrong key or corrupted file)."
115+
) from err
116+
117+
try:
118+
parsed = json.loads(plaintext.decode("utf-8"))
119+
except json.JSONDecodeError as err:
120+
raise SessionCryptoError("Decrypted session is not valid JSON.") from err
121+
122+
if not isinstance(parsed, dict):
123+
raise SessionCryptoError("Invalid session format.")
124+
125+
return parsed

cecli/sessions.py

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Dict, List, Optional
77

88
from cecli import models
9+
from cecli.helpers import crypto as session_crypto
910
from cecli.helpers.conversation import ConversationService, MessageTag
1011

1112

@@ -22,6 +23,65 @@ def _get_session_directory(self) -> Path:
2223
os.makedirs(session_dir, exist_ok=True)
2324
return session_dir
2425

26+
def _session_encrypt_settings(self) -> tuple[bool, bytes | None]:
27+
args = getattr(self.coder, "args", None)
28+
if not args or not getattr(args, "session_encrypt", False):
29+
return False, None
30+
key_file = getattr(args, "session_key_file", None)
31+
return True, session_crypto.resolve_key(key_file=key_file)
32+
33+
def _read_session_file(self, session_file: Path) -> dict | None:
34+
try:
35+
data = session_file.read_bytes()
36+
except OSError as e:
37+
self.io.tool_error(f"Error reading session: {e}")
38+
return None
39+
try:
40+
if session_crypto.is_encrypted_payload(data):
41+
args = getattr(self.coder, "args", None)
42+
key_file = getattr(args, "session_key_file", None) if args else None
43+
key = session_crypto.resolve_key(key_file=key_file)
44+
if not key:
45+
self.io.tool_error(
46+
"Session is encrypted but no key is configured "
47+
f"({session_crypto.KEY_ENV} or --session-key-file)."
48+
)
49+
return None
50+
return session_crypto.decrypt_session_bytes(data, key)
51+
parsed = json.loads(data.decode("utf-8"))
52+
if not isinstance(parsed, dict):
53+
self.io.tool_error("Invalid session format.")
54+
return None
55+
return parsed
56+
except session_crypto.SessionCryptoError as e:
57+
self.io.tool_error(str(e))
58+
return None
59+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
60+
self.io.tool_error(f"Error loading session: {e}")
61+
return None
62+
63+
def _write_session_file(self, session_file: Path, session_data: dict) -> bool:
64+
encrypt_enabled, key = self._session_encrypt_settings()
65+
try:
66+
if encrypt_enabled:
67+
if not key:
68+
self.io.tool_error(
69+
"Session encryption is enabled but no key is configured "
70+
f"({session_crypto.KEY_ENV} or --session-key-file)."
71+
)
72+
return False
73+
session_file.write_bytes(session_crypto.encrypt_session_dict(session_data, key))
74+
else:
75+
with open(session_file, "w", encoding="utf-8") as f:
76+
json.dump(session_data, f, indent=2)
77+
return True
78+
except session_crypto.SessionCryptoError as e:
79+
self.io.tool_error(str(e))
80+
return False
81+
except OSError as e:
82+
self.io.tool_error(f"Error saving session: {e}")
83+
return False
84+
2585
def save_session(self, session_name: str, output=True) -> bool:
2686
"""Save the current chat session to a named file."""
2787
if not session_name:
@@ -39,11 +99,12 @@ def save_session(self, session_name: str, output=True) -> bool:
3999

40100
try:
41101
session_data = self._build_session_data(session_name)
42-
with open(session_file, "w", encoding="utf-8") as f:
43-
json.dump(session_data, f, indent=2)
102+
if not self._write_session_file(session_file, session_data):
103+
return False
44104

45105
if output:
46-
self.io.tool_output(f"Session saved: {session_file}")
106+
suffix = " (encrypted)" if self._session_encrypt_settings()[0] else ""
107+
self.io.tool_output(f"Session saved: {session_file}{suffix}")
47108

48109
return True
49110

@@ -63,8 +124,27 @@ def list_sessions(self) -> List[Dict]:
63124
sessions = []
64125
for session_file in sorted(session_files, key=lambda x: x.stat().st_mtime, reverse=True):
65126
try:
66-
with open(session_file, "r", encoding="utf-8") as f:
67-
session_data = json.load(f)
127+
raw = session_file.read_bytes()
128+
if session_crypto.is_encrypted_payload(raw):
129+
_, key = self._session_encrypt_settings()
130+
if not key:
131+
sessions.append(
132+
{
133+
"name": session_file.stem,
134+
"file": session_file,
135+
"model": "encrypted",
136+
"edit_format": "—",
137+
"num_messages": 0,
138+
"num_files": 0,
139+
"encrypted": True,
140+
}
141+
)
142+
continue
143+
session_data = session_crypto.decrypt_session_bytes(raw, key)
144+
else:
145+
session_data = json.loads(raw.decode("utf-8"))
146+
if not isinstance(session_data, dict):
147+
raise ValueError("not a session object")
68148

69149
session_info = {
70150
"name": session_file.stem,
@@ -80,6 +160,7 @@ def list_sessions(self) -> List[Dict]:
80160
+ len(session_data.get("files", {}).get("read_only", []))
81161
+ len(session_data.get("files", {}).get("read_only_stubs", []))
82162
),
163+
"encrypted": session_crypto.is_encrypted_payload(raw),
83164
}
84165
sessions.append(session_info)
85166

@@ -99,15 +180,10 @@ async def load_session(self, session_identifier: str, switch=True, quiet: bool =
99180
if not session_file:
100181
return False
101182

102-
try:
103-
with open(session_file, "r", encoding="utf-8") as f:
104-
session_data = json.load(f)
105-
except Exception as e:
106-
if not quiet:
107-
self.io.tool_error(f"Error loading session: {e}")
183+
session_data = self._read_session_file(session_file)
184+
if session_data is None:
108185
return False
109186

110-
# Verify session format
111187
if not isinstance(session_data, dict) or "version" not in session_data:
112188
if not quiet:
113189
self.io.tool_error("Invalid session format.")

cecli/website/docs/usage/sessions.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,17 @@ Sessions are stored as JSON files in the `.cecli/sessions/` directory within you
158158
### Version Control
159159
- Consider adding `.cecli/sessions/` to your `.gitignore` if sessions contain sensitive information
160160

161+
### Optional encryption (AES-256-GCM)
162+
163+
When enabled, session files on disk are encrypted (plaintext JSON is unchanged when disabled).
164+
165+
```bash
166+
export CECLI_SESSION_KEY="$(python -c 'import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())')"
167+
cecli --session-encrypt --auto-save
168+
```
169+
170+
Or use `--session-key-file` pointing at a file with the same urlsafe-base64 32-byte key.
171+
161172
## Troubleshooting
162173

163174
### Session Not Found

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ configargparse==1.7.1
6767
cryptography==46.0.3
6868
# via
6969
# -c requirements/common-constraints.txt
70+
# -r requirements/requirements.in
7071
# pyjwt
7172
diff-match-patch==20241021
7273
# via

requirements/common-constraints.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ configargparse==1.7.1
6666
contourpy==1.3.3
6767
# via matplotlib
6868
cryptography==46.0.3
69-
# via pyjwt
69+
# via
70+
# -r requirements/requirements.in
71+
# pyjwt
7072
cycler==0.12.1
7173
# via matplotlib
7274
dataclasses-json==0.6.7

requirements/requirements.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ tomlkit>=0.14.0
3232
truststore
3333
xxhash>=3.6.0
3434
py-cymbal>=0.1.24
35+
cryptography>=42.0.0
3536

3637
# Replaced networkx with rustworkx for better performance in repomap
3738
rustworkx>=0.15.0

tests/basic/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Shared fixtures for cecli basic tests."""
2+
3+
import base64
4+
import os
5+
6+
import pytest
7+
8+
from cecli.helpers import crypto as session_crypto
9+
10+
11+
@pytest.fixture
12+
def session_key32():
13+
return os.urandom(session_crypto.KEY_BYTES)
14+
15+
16+
@pytest.fixture
17+
def session_key_b64(session_key32):
18+
return base64.urlsafe_b64encode(session_key32).decode().rstrip("=")
19+
20+
21+
@pytest.fixture
22+
def session_key_env(monkeypatch, session_key32, session_key_b64):
23+
monkeypatch.setenv(session_crypto.KEY_ENV, session_key_b64)
24+
return session_key32

tests/basic/test_session_args.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""CLI args for session encryption and auto-save."""
2+
3+
from cecli.args import get_parser
4+
5+
6+
def test_session_encrypt_defaults_off():
7+
parser = get_parser([], "/tmp/project")
8+
args = parser.parse_args([])
9+
assert args.session_encrypt is False
10+
assert args.session_key_file is None
11+
assert args.auto_save is False
12+
assert args.auto_load is False
13+
assert args.auto_save_session_name == "auto-save"
14+
15+
16+
def test_session_encrypt_flag():
17+
parser = get_parser([], "/tmp/project")
18+
args = parser.parse_args(["--session-encrypt"])
19+
assert args.session_encrypt is True
20+
21+
22+
def test_session_encrypt_no_flag():
23+
parser = get_parser([], "/tmp/project")
24+
args = parser.parse_args(["--no-session-encrypt"])
25+
assert args.session_encrypt is False
26+
27+
28+
def test_session_key_file_flag():
29+
parser = get_parser([], "/tmp/project")
30+
args = parser.parse_args(["--session-key-file", "/tmp/key.bin"])
31+
assert args.session_key_file == "/tmp/key.bin"

0 commit comments

Comments
 (0)