klickd 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+
2
+ # Python
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+
7
+ # Node
8
+ node_modules/
9
+ dist/
klickd-3.0.0/PKG-INFO ADDED
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: klickd
3
+ Version: 3.0.0
4
+ Summary: Official Python library for reading and writing .klickd portable AI context files
5
+ Project-URL: Homepage, https://klickd.app
6
+ Project-URL: Repository, https://github.com/Davincc77/klickdskill
7
+ Project-URL: Documentation, https://github.com/Davincc77/klickdskill/blob/main/SPEC.md
8
+ Author-email: "Vince C." <Luxlearn@pm.me>
9
+ License: CC0-1.0
10
+ Keywords: ai-context,encrypted,klickd,memory,portable
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security :: Cryptography
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: argon2-cffi>=23.1
23
+ Requires-Dist: cryptography>=41.0
24
+ Requires-Dist: jcs>=0.2
25
+ Description-Content-Type: text/markdown
26
+
27
+ # klickd
28
+
29
+ Official Python library for reading and writing `.klickd` portable AI context files.
30
+
31
+ **One soul. Any model. Any body.**
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/klickd)](https://pypi.org/project/klickd/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/klickd)](https://pypi.org/project/klickd/)
35
+ [![License: CC0-1.0](https://img.shields.io/badge/License-CC0_1.0-lightgrey.svg)](https://creativecommons.org/publicdomain/zero/1.0/)
36
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20262530.svg)](https://doi.org/10.5281/zenodo.20262530)
37
+
38
+ ---
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install klickd
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Quick start
49
+
50
+ ### Load (decrypt) a `.klickd` file
51
+
52
+ ```python
53
+ from klickd import load_klickd
54
+
55
+ with open("context.klickd", "rb") as f:
56
+ payload = load_klickd(f.read(), passphrase="my-passphrase")
57
+
58
+ print(payload["identity"]["name"])
59
+ print(payload["memory"])
60
+ ```
61
+
62
+ ### Save (encrypt) a `.klickd` file
63
+
64
+ ```python
65
+ from klickd import save_klickd
66
+
67
+ payload = {
68
+ "payload_schema_version": "3.0.0",
69
+ "domain_schema_version": "1.0.0",
70
+ "identity": {"name": "Alice", "language": "en", "timezone": "Europe/Luxembourg"},
71
+ "agent_instructions": "Be concise.",
72
+ "memory": [],
73
+ }
74
+
75
+ klickd_bytes = save_klickd(payload, passphrase="my-passphrase", domain="education")
76
+
77
+ with open("context.klickd", "wb") as f:
78
+ f.write(klickd_bytes)
79
+ ```
80
+
81
+ ### Legacy v2.x files (PBKDF2)
82
+
83
+ ```python
84
+ payload = load_klickd(file_bytes, passphrase="my-passphrase", legacy=True)
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Cryptographic specification (v3.0)
90
+
91
+ | Parameter | Value |
92
+ |---------------|-----------------------------------------|
93
+ | KDF (default) | Argon2id — m=65536, t=3, p=1 |
94
+ | KDF (legacy) | PBKDF2-SHA256 / 600 000 iterations |
95
+ | Cipher | AES-256-GCM |
96
+ | AAD | RFC 8785 JCS over 6 canonical fields |
97
+ | Base64 | RFC 4648 §4 standard padded |
98
+ | Salt | 16 bytes (CSPRNG) |
99
+ | IV | 12 bytes (CSPRNG) |
100
+
101
+ ---
102
+
103
+ ## Error codes
104
+
105
+ | Code | HTTP | Meaning |
106
+ |-----------------------|------|------------------------------------------|
107
+ | `KLICKD_E_AUTH` | 401 | Wrong passphrase / GCM tag mismatch |
108
+ | `KLICKD_E_VERSION` | 400 | Unsupported `klickd_version` major |
109
+ | `KLICKD_E_FORMAT` | 400 | Malformed JSON envelope / missing fields |
110
+ | `KLICKD_E_KDF` | 400 | Unknown or unavailable KDF |
111
+ | `KLICKD_E_WEAK_PASS` | 422 | Passphrase shorter than 8 characters |
112
+ | `KLICKD_E_SCHEMA` | 400 | Missing `payload_schema_version` |
113
+
114
+ ```python
115
+ from klickd import KlickdError, KlickdErrorCode
116
+
117
+ try:
118
+ payload = load_klickd(data, passphrase="wrong")
119
+ except KlickdError as e:
120
+ print(e.code) # KlickdErrorCode.AUTH
121
+ print(e.http_status) # 401
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Links
127
+
128
+ - Specification: [SPEC.md](https://github.com/Davincc77/klickdskill/blob/main/SPEC.md)
129
+ - Repository: [github.com/Davincc77/klickdskill](https://github.com/Davincc77/klickdskill)
130
+ - DOI: [10.5281/zenodo.20262530](https://doi.org/10.5281/zenodo.20262530)
131
+ - Homepage: [klickd.app](https://klickd.app)
132
+
133
+ ---
134
+
135
+ ## License
136
+
137
+ [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) — Public Domain Dedication.
138
+ Author: Vince C. (Luxlearn, Luxembourg)
klickd-3.0.0/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # klickd
2
+
3
+ Official Python library for reading and writing `.klickd` portable AI context files.
4
+
5
+ **One soul. Any model. Any body.**
6
+
7
+ [![PyPI version](https://img.shields.io/pypi/v/klickd)](https://pypi.org/project/klickd/)
8
+ [![Python](https://img.shields.io/pypi/pyversions/klickd)](https://pypi.org/project/klickd/)
9
+ [![License: CC0-1.0](https://img.shields.io/badge/License-CC0_1.0-lightgrey.svg)](https://creativecommons.org/publicdomain/zero/1.0/)
10
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20262530.svg)](https://doi.org/10.5281/zenodo.20262530)
11
+
12
+ ---
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install klickd
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Quick start
23
+
24
+ ### Load (decrypt) a `.klickd` file
25
+
26
+ ```python
27
+ from klickd import load_klickd
28
+
29
+ with open("context.klickd", "rb") as f:
30
+ payload = load_klickd(f.read(), passphrase="my-passphrase")
31
+
32
+ print(payload["identity"]["name"])
33
+ print(payload["memory"])
34
+ ```
35
+
36
+ ### Save (encrypt) a `.klickd` file
37
+
38
+ ```python
39
+ from klickd import save_klickd
40
+
41
+ payload = {
42
+ "payload_schema_version": "3.0.0",
43
+ "domain_schema_version": "1.0.0",
44
+ "identity": {"name": "Alice", "language": "en", "timezone": "Europe/Luxembourg"},
45
+ "agent_instructions": "Be concise.",
46
+ "memory": [],
47
+ }
48
+
49
+ klickd_bytes = save_klickd(payload, passphrase="my-passphrase", domain="education")
50
+
51
+ with open("context.klickd", "wb") as f:
52
+ f.write(klickd_bytes)
53
+ ```
54
+
55
+ ### Legacy v2.x files (PBKDF2)
56
+
57
+ ```python
58
+ payload = load_klickd(file_bytes, passphrase="my-passphrase", legacy=True)
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Cryptographic specification (v3.0)
64
+
65
+ | Parameter | Value |
66
+ |---------------|-----------------------------------------|
67
+ | KDF (default) | Argon2id — m=65536, t=3, p=1 |
68
+ | KDF (legacy) | PBKDF2-SHA256 / 600 000 iterations |
69
+ | Cipher | AES-256-GCM |
70
+ | AAD | RFC 8785 JCS over 6 canonical fields |
71
+ | Base64 | RFC 4648 §4 standard padded |
72
+ | Salt | 16 bytes (CSPRNG) |
73
+ | IV | 12 bytes (CSPRNG) |
74
+
75
+ ---
76
+
77
+ ## Error codes
78
+
79
+ | Code | HTTP | Meaning |
80
+ |-----------------------|------|------------------------------------------|
81
+ | `KLICKD_E_AUTH` | 401 | Wrong passphrase / GCM tag mismatch |
82
+ | `KLICKD_E_VERSION` | 400 | Unsupported `klickd_version` major |
83
+ | `KLICKD_E_FORMAT` | 400 | Malformed JSON envelope / missing fields |
84
+ | `KLICKD_E_KDF` | 400 | Unknown or unavailable KDF |
85
+ | `KLICKD_E_WEAK_PASS` | 422 | Passphrase shorter than 8 characters |
86
+ | `KLICKD_E_SCHEMA` | 400 | Missing `payload_schema_version` |
87
+
88
+ ```python
89
+ from klickd import KlickdError, KlickdErrorCode
90
+
91
+ try:
92
+ payload = load_klickd(data, passphrase="wrong")
93
+ except KlickdError as e:
94
+ print(e.code) # KlickdErrorCode.AUTH
95
+ print(e.http_status) # 401
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Links
101
+
102
+ - Specification: [SPEC.md](https://github.com/Davincc77/klickdskill/blob/main/SPEC.md)
103
+ - Repository: [github.com/Davincc77/klickdskill](https://github.com/Davincc77/klickdskill)
104
+ - DOI: [10.5281/zenodo.20262530](https://doi.org/10.5281/zenodo.20262530)
105
+ - Homepage: [klickd.app](https://klickd.app)
106
+
107
+ ---
108
+
109
+ ## License
110
+
111
+ [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) — Public Domain Dedication.
112
+ Author: Vince C. (Luxlearn, Luxembourg)
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "klickd"
7
+ version = "3.0.0"
8
+ description = "Official Python library for reading and writing .klickd portable AI context files"
9
+ readme = "README.md"
10
+ license = {text = "CC0-1.0"}
11
+ authors = [{name = "Vince C.", email = "Luxlearn@pm.me"}]
12
+ keywords = ["klickd", "ai-context", "portable", "memory", "encrypted"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Security :: Cryptography",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+ requires-python = ">=3.9"
26
+ dependencies = [
27
+ "cryptography>=41.0",
28
+ "argon2-cffi>=23.1",
29
+ "jcs>=0.2",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://klickd.app"
34
+ Repository = "https://github.com/Davincc77/klickdskill"
35
+ Documentation = "https://github.com/Davincc77/klickdskill/blob/main/SPEC.md"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/klickd"]
@@ -0,0 +1,35 @@
1
+ # klickd — Official Python library for .klickd portable AI context files
2
+ # SPDX-License-Identifier: CC0-1.0
3
+ #
4
+ # One soul. Any model. Any body.
5
+ #
6
+ # Repository: https://github.com/Davincc77/klickdskill
7
+ # DOI: https://doi.org/10.5281/zenodo.20262530
8
+
9
+ from .decode import load_klickd
10
+ from .encode import save_klickd
11
+ from .errors import KlickdError, KlickdErrorCode, HTTP_STATUS
12
+ from ._types import (
13
+ KlickdPayload,
14
+ KlickdEnvelope,
15
+ KlickdMemoryEntry,
16
+ KlickdIdentity,
17
+ KlickdContext,
18
+ KlickdKnowledge,
19
+ )
20
+
21
+ __version__ = "3.0.0"
22
+ __all__ = [
23
+ "load_klickd",
24
+ "save_klickd",
25
+ "KlickdError",
26
+ "KlickdErrorCode",
27
+ "HTTP_STATUS",
28
+ "KlickdPayload",
29
+ "KlickdEnvelope",
30
+ "KlickdMemoryEntry",
31
+ "KlickdIdentity",
32
+ "KlickdContext",
33
+ "KlickdKnowledge",
34
+ "__version__",
35
+ ]
@@ -0,0 +1,84 @@
1
+ # .klickd v3 — Python TypedDict definitions
2
+ # SPDX-License-Identifier: CC0-1.0
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, List, Literal, Optional
7
+ from typing_extensions import TypedDict, Required
8
+
9
+
10
+ class KlickdKdfArgon2idParams(TypedDict):
11
+ m: int
12
+ t: int
13
+ p: int
14
+
15
+
16
+ class KlickdKdfArgon2id(TypedDict):
17
+ name: Literal["argon2id"]
18
+ params: KlickdKdfArgon2idParams
19
+ salt: str # base64
20
+
21
+
22
+ class KlickdKdfPbkdf2Params(TypedDict):
23
+ iterations: int
24
+
25
+
26
+ class KlickdKdfPbkdf2(TypedDict):
27
+ name: Literal["pbkdf2-sha256"]
28
+ params: KlickdKdfPbkdf2Params
29
+ salt: str # base64
30
+
31
+
32
+ class KlickdCipher(TypedDict):
33
+ name: Literal["AES-256-GCM"]
34
+ iv: str # base64
35
+
36
+
37
+ class KlickdEnvelope(TypedDict, total=False):
38
+ klickd_version: Required[str]
39
+ encrypted: Required[bool]
40
+ domain: Required[str]
41
+ created_at: Required[str] # RFC 3339 UTC Z
42
+ kdf: Required[dict] # KlickdKdfArgon2id | KlickdKdfPbkdf2
43
+ cipher: Required[KlickdCipher]
44
+ ciphertext: Required[str] # base64
45
+
46
+
47
+ class KlickdMemoryEntry(TypedDict, total=False):
48
+ id: Required[str] # UUID v4
49
+ ts: Required[str] # RFC 3339 UTC Z
50
+ role: Required[str] # user | assistant | system
51
+ content: Required[str]
52
+ modality: Required[str] # text | image | audio | tool_call
53
+ tags: List[str]
54
+
55
+
56
+ class KlickdIdentity(TypedDict, total=False):
57
+ name: str
58
+ language: str
59
+ timezone: str
60
+ communication_style: str
61
+
62
+
63
+ class KlickdContext(TypedDict, total=False):
64
+ current_state: str
65
+ decisions_locked: List[str]
66
+ artifacts: List[Any]
67
+ summary: str
68
+
69
+
70
+ class KlickdKnowledge(TypedDict, total=False):
71
+ mastered: List[str]
72
+ gaps: List[str]
73
+ next_steps: List[str]
74
+
75
+
76
+ class KlickdPayload(TypedDict, total=False):
77
+ payload_schema_version: Required[str]
78
+ domain_schema_version: Required[str]
79
+ identity: KlickdIdentity
80
+ agent_instructions: str
81
+ user_preferences: dict
82
+ context: KlickdContext
83
+ knowledge: KlickdKnowledge
84
+ memory: List[KlickdMemoryEntry]
@@ -0,0 +1,181 @@
1
+ # .klickd v3 — decode (load) implementation
2
+ # SPDX-License-Identifier: CC0-1.0
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import hashlib
8
+ import json
9
+ from typing import Any
10
+
11
+ import jcs
12
+ from argon2.low_level import hash_secret_raw, Type
13
+ from cryptography.exceptions import InvalidTag
14
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
15
+
16
+ from .errors import KlickdError, KlickdErrorCode
17
+
18
+ SUPPORTED_MAJOR = 3
19
+ REQUIRED_ENVELOPE_FIELDS = frozenset(
20
+ ["klickd_version", "encrypted", "domain", "created_at", "kdf", "cipher", "ciphertext"]
21
+ )
22
+
23
+
24
+ def _b64_decode(s: str) -> bytes:
25
+ """RFC 4648 §4 standard padded base64 decode."""
26
+ return base64.b64decode(s)
27
+
28
+
29
+ def _derive_key_argon2id(passphrase: str, salt: bytes, m: int, t: int, p: int) -> bytes:
30
+ return hash_secret_raw(
31
+ secret=passphrase.encode("utf-8"),
32
+ salt=salt,
33
+ time_cost=t,
34
+ memory_cost=m,
35
+ parallelism=p,
36
+ hash_len=32,
37
+ type=Type.ID,
38
+ )
39
+
40
+
41
+ def _derive_key_pbkdf2(passphrase: str, salt: bytes, iterations: int) -> bytes:
42
+ return hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32)
43
+
44
+
45
+ def load_klickd(
46
+ file_bytes: bytes,
47
+ passphrase: str | None = None,
48
+ legacy: bool = False,
49
+ ) -> dict[str, Any]:
50
+ """
51
+ Decrypt and parse a .klickd envelope.
52
+
53
+ Args:
54
+ file_bytes: Raw .klickd file content (UTF-8 JSON bytes).
55
+ passphrase: Decryption passphrase.
56
+ legacy: Set ``True`` to allow legacy PBKDF2-SHA256/600k v2.x files.
57
+ Default: ``False``.
58
+
59
+ Returns:
60
+ The decrypted payload as a dict (KlickdPayload-compatible).
61
+
62
+ Raises:
63
+ KlickdError: On any format, version, authentication, or schema error.
64
+ """
65
+ # Parse envelope JSON
66
+ try:
67
+ envelope: dict[str, Any] = json.loads(file_bytes.decode("utf-8"))
68
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
69
+ raise KlickdError(KlickdErrorCode.FORMAT, f"Invalid JSON envelope: {exc}") from exc
70
+
71
+ if not isinstance(envelope, dict):
72
+ raise KlickdError(KlickdErrorCode.FORMAT, "Envelope must be a JSON object.")
73
+
74
+ # Validate required fields
75
+ missing = REQUIRED_ENVELOPE_FIELDS - envelope.keys()
76
+ if missing:
77
+ raise KlickdError(
78
+ KlickdErrorCode.FORMAT,
79
+ f"Missing required envelope fields: {', '.join(sorted(missing))}",
80
+ )
81
+
82
+ # Check major version
83
+ try:
84
+ major = int(str(envelope["klickd_version"]).split(".")[0])
85
+ except (ValueError, IndexError) as exc:
86
+ raise KlickdError(
87
+ KlickdErrorCode.VERSION,
88
+ f"Cannot parse klickd_version: {envelope['klickd_version']}",
89
+ ) from exc
90
+
91
+ if major != SUPPORTED_MAJOR:
92
+ raise KlickdError(
93
+ KlickdErrorCode.VERSION,
94
+ f"Unsupported klickd_version major: {envelope['klickd_version']}. "
95
+ f"This library supports v{SUPPORTED_MAJOR}.x.",
96
+ )
97
+
98
+ # Reconstruct AAD: JCS over the 6 canonical fields
99
+ envelope_for_aad: dict[str, Any] = {
100
+ "klickd_version": envelope["klickd_version"],
101
+ "encrypted": envelope["encrypted"],
102
+ "domain": envelope["domain"],
103
+ "created_at": envelope["created_at"],
104
+ "kdf": envelope["kdf"],
105
+ "cipher": envelope["cipher"],
106
+ }
107
+ aad: bytes = jcs.canonicalize(envelope_for_aad)
108
+
109
+ # Require passphrase
110
+ if not passphrase:
111
+ raise KlickdError(
112
+ KlickdErrorCode.AUTH,
113
+ "A passphrase is required to decrypt this file.",
114
+ )
115
+
116
+ # Derive key
117
+ kdf = envelope["kdf"]
118
+ kdf_name: str = kdf.get("name", "")
119
+
120
+ if kdf_name == "argon2id":
121
+ try:
122
+ salt = _b64_decode(kdf["salt"])
123
+ params = kdf["params"]
124
+ key = _derive_key_argon2id(passphrase, salt, params["m"], params["t"], params["p"])
125
+ except (KeyError, TypeError) as exc:
126
+ raise KlickdError(KlickdErrorCode.FORMAT, f"Invalid Argon2id KDF params: {exc}") from exc
127
+
128
+ elif kdf_name == "pbkdf2-sha256":
129
+ if not legacy:
130
+ raise KlickdError(
131
+ KlickdErrorCode.KDF,
132
+ "Legacy PBKDF2 KDF detected. Set legacy=True to enable reading legacy v2.x files.",
133
+ )
134
+ try:
135
+ salt = _b64_decode(kdf["salt"])
136
+ iterations = kdf["params"]["iterations"]
137
+ key = _derive_key_pbkdf2(passphrase, salt, iterations)
138
+ except (KeyError, TypeError) as exc:
139
+ raise KlickdError(KlickdErrorCode.FORMAT, f"Invalid PBKDF2 KDF params: {exc}") from exc
140
+
141
+ else:
142
+ raise KlickdError(KlickdErrorCode.KDF, f"Unknown KDF: '{kdf_name}'.")
143
+
144
+ # Decode ciphertext (ciphertext || 16-byte GCM tag, per AESGCM convention)
145
+ try:
146
+ ciphertext_with_tag = _b64_decode(envelope["ciphertext"])
147
+ except Exception as exc:
148
+ raise KlickdError(KlickdErrorCode.FORMAT, f"Invalid ciphertext base64: {exc}") from exc
149
+
150
+ if len(ciphertext_with_tag) < 16:
151
+ raise KlickdError(KlickdErrorCode.FORMAT, "Ciphertext too short.")
152
+
153
+ # Decrypt AES-256-GCM
154
+ try:
155
+ iv = _b64_decode(envelope["cipher"]["iv"])
156
+ except (KeyError, Exception) as exc:
157
+ raise KlickdError(KlickdErrorCode.FORMAT, f"Invalid cipher IV: {exc}") from exc
158
+
159
+ try:
160
+ aesgcm = AESGCM(key)
161
+ plaintext = aesgcm.decrypt(iv, ciphertext_with_tag, aad)
162
+ except InvalidTag as exc:
163
+ raise KlickdError(
164
+ KlickdErrorCode.AUTH,
165
+ "Decryption failed: wrong passphrase or corrupted file.",
166
+ ) from exc
167
+
168
+ # Parse payload JSON
169
+ try:
170
+ payload: dict[str, Any] = json.loads(plaintext.decode("utf-8"))
171
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
172
+ raise KlickdError(KlickdErrorCode.FORMAT, f"Decrypted data is not valid JSON: {exc}") from exc
173
+
174
+ # Validate payload_schema_version
175
+ if not payload.get("payload_schema_version"):
176
+ raise KlickdError(
177
+ KlickdErrorCode.SCHEMA,
178
+ "Decrypted payload is missing payload_schema_version.",
179
+ )
180
+
181
+ return payload
@@ -0,0 +1,116 @@
1
+ # .klickd v3 — encode (save) implementation
2
+ # SPDX-License-Identifier: CC0-1.0
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import json
8
+ import os
9
+ from typing import Any
10
+
11
+ import jcs
12
+ from argon2.low_level import hash_secret_raw, Type
13
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
14
+
15
+ from .errors import KlickdError, KlickdErrorCode
16
+
17
+ KLICKD_VERSION = "3.0.0"
18
+ DEFAULT_KDF_PARAMS = {"m": 65536, "t": 3, "p": 1}
19
+
20
+
21
+ def _b64_encode(data: bytes) -> str:
22
+ """RFC 4648 §4 standard padded base64."""
23
+ return base64.b64encode(data).decode("ascii")
24
+
25
+
26
+ def _derive_key_argon2id(passphrase: str, salt: bytes, m: int, t: int, p: int) -> bytes:
27
+ """Derive a 32-byte key using Argon2id."""
28
+ return hash_secret_raw(
29
+ secret=passphrase.encode("utf-8"),
30
+ salt=salt,
31
+ time_cost=t,
32
+ memory_cost=m,
33
+ parallelism=p,
34
+ hash_len=32,
35
+ type=Type.ID,
36
+ )
37
+
38
+
39
+ def save_klickd(
40
+ payload: dict[str, Any],
41
+ passphrase: str,
42
+ domain: str = "education",
43
+ kdf_params: dict[str, int] | None = None,
44
+ ) -> bytes:
45
+ """
46
+ Encrypt a KlickdPayload dict and return a .klickd JSON envelope as UTF-8 bytes.
47
+
48
+ Args:
49
+ payload: Dict conforming to KlickdPayload schema.
50
+ Must include ``payload_schema_version``.
51
+ passphrase: Encryption passphrase (minimum 8 characters).
52
+ domain: .klickd domain tag. Default: ``"education"``.
53
+ kdf_params: Argon2id parameter overrides.
54
+ Defaults to ``{"m": 65536, "t": 3, "p": 1}``.
55
+
56
+ Returns:
57
+ UTF-8 bytes of the complete .klickd JSON envelope.
58
+
59
+ Raises:
60
+ KlickdError: On validation or cryptographic failure.
61
+ """
62
+ params = kdf_params or DEFAULT_KDF_PARAMS
63
+
64
+ # Validate passphrase
65
+ if not passphrase or len(passphrase) < 8:
66
+ raise KlickdError(KlickdErrorCode.WEAK_PASS, "Passphrase must be at least 8 characters.")
67
+
68
+ # Validate payload schema version
69
+ if not payload.get("payload_schema_version"):
70
+ raise KlickdError(KlickdErrorCode.SCHEMA, "payload_schema_version is required.")
71
+
72
+ # Generate fresh CSPRNG salt (16 bytes) and IV (12 bytes)
73
+ salt = os.urandom(16)
74
+ iv = os.urandom(12)
75
+
76
+ # Derive 32-byte key
77
+ key = _derive_key_argon2id(
78
+ passphrase, salt, params["m"], params["t"], params["p"]
79
+ )
80
+
81
+ # Build the 6-field envelope object for AAD (without ciphertext)
82
+ from datetime import datetime, timezone
83
+ created_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
84
+
85
+ envelope_for_aad: dict[str, Any] = {
86
+ "klickd_version": KLICKD_VERSION,
87
+ "encrypted": True,
88
+ "domain": domain,
89
+ "created_at": created_at,
90
+ "kdf": {
91
+ "name": "argon2id",
92
+ "params": {"m": params["m"], "t": params["t"], "p": params["p"]},
93
+ "salt": _b64_encode(salt),
94
+ },
95
+ "cipher": {
96
+ "name": "AES-256-GCM",
97
+ "iv": _b64_encode(iv),
98
+ },
99
+ }
100
+
101
+ # AAD = RFC 8785 JCS (JSON Canonicalization Scheme)
102
+ aad: bytes = jcs.canonicalize(envelope_for_aad)
103
+
104
+ # Encrypt payload JSON with AES-256-GCM
105
+ # cryptography's AESGCM appends the 16-byte tag to ciphertext
106
+ aesgcm = AESGCM(key)
107
+ plaintext = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
108
+ ciphertext_with_tag = aesgcm.encrypt(iv, plaintext, aad)
109
+
110
+ # Build final envelope
111
+ envelope: dict[str, Any] = {
112
+ **envelope_for_aad,
113
+ "ciphertext": _b64_encode(ciphertext_with_tag),
114
+ }
115
+
116
+ return json.dumps(envelope, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
@@ -0,0 +1,32 @@
1
+ # .klickd error definitions
2
+ # SPDX-License-Identifier: CC0-1.0
3
+
4
+ from enum import Enum
5
+
6
+
7
+ class KlickdErrorCode(str, Enum):
8
+ AUTH = "KLICKD_E_AUTH" # Wrong passphrase / GCM tag mismatch
9
+ VERSION = "KLICKD_E_VERSION" # Unsupported klickd_version major
10
+ FORMAT = "KLICKD_E_FORMAT" # Malformed envelope JSON / missing fields
11
+ KDF = "KLICKD_E_KDF" # Unknown/unsupported KDF
12
+ WEAK_PASS = "KLICKD_E_WEAK_PASS" # Passphrase < 8 characters
13
+ SCHEMA = "KLICKD_E_SCHEMA" # Missing payload_schema_version
14
+
15
+
16
+ HTTP_STATUS: dict[KlickdErrorCode, int] = {
17
+ KlickdErrorCode.AUTH: 401,
18
+ KlickdErrorCode.VERSION: 400,
19
+ KlickdErrorCode.FORMAT: 400,
20
+ KlickdErrorCode.KDF: 400,
21
+ KlickdErrorCode.WEAK_PASS: 422,
22
+ KlickdErrorCode.SCHEMA: 400,
23
+ }
24
+
25
+
26
+ class KlickdError(Exception):
27
+ """Raised for all .klickd format and cryptographic errors."""
28
+
29
+ def __init__(self, code: KlickdErrorCode, message: str) -> None:
30
+ super().__init__(f"{code}: {message}")
31
+ self.code = code
32
+ self.http_status: int = HTTP_STATUS[code]
File without changes
@@ -0,0 +1,134 @@
1
+ # klickd — roundtrip test
2
+ # SPDX-License-Identifier: CC0-1.0
3
+
4
+ import json
5
+ import pytest
6
+ from klickd import load_klickd, save_klickd, KlickdError, KlickdErrorCode
7
+
8
+ TEST_PAYLOAD = {
9
+ "payload_schema_version": "3.0.0",
10
+ "domain_schema_version": "1.0.0",
11
+ "identity": {
12
+ "name": "Test User",
13
+ "language": "en",
14
+ "timezone": "Europe/Luxembourg",
15
+ },
16
+ "agent_instructions": "Be concise and precise.",
17
+ "memory": [
18
+ {
19
+ "id": "00000000-0000-4000-a000-000000000001",
20
+ "ts": "2025-01-01T00:00:00Z",
21
+ "role": "user",
22
+ "content": "Hello from the test suite.",
23
+ "modality": "text",
24
+ "tags": ["test"],
25
+ }
26
+ ],
27
+ }
28
+
29
+ PASSPHRASE = "correct-horse-battery-staple"
30
+
31
+
32
+ class TestRoundtrip:
33
+ def test_save_and_load(self):
34
+ """Encrypt and decrypt a payload successfully."""
35
+ envelope_bytes = save_klickd(TEST_PAYLOAD, PASSPHRASE, domain="education")
36
+
37
+ # Verify the envelope structure
38
+ envelope = json.loads(envelope_bytes)
39
+ assert envelope["klickd_version"] == "3.0.0"
40
+ assert envelope["encrypted"] is True
41
+ assert envelope["domain"] == "education"
42
+ assert envelope["kdf"]["name"] == "argon2id"
43
+ assert isinstance(envelope["ciphertext"], str)
44
+
45
+ # Decrypt and verify payload
46
+ payload = load_klickd(envelope_bytes, passphrase=PASSPHRASE)
47
+
48
+ assert payload["payload_schema_version"] == "3.0.0"
49
+ assert payload["domain_schema_version"] == "1.0.0"
50
+ assert payload["identity"]["name"] == "Test User"
51
+ assert payload["memory"][0]["content"] == "Hello from the test suite."
52
+
53
+ def test_wrong_passphrase(self):
54
+ """Wrong passphrase raises KLICKD_E_AUTH."""
55
+ envelope_bytes = save_klickd(TEST_PAYLOAD, PASSPHRASE)
56
+ with pytest.raises(KlickdError) as exc_info:
57
+ load_klickd(envelope_bytes, passphrase="wrong-passphrase")
58
+ assert exc_info.value.code == KlickdErrorCode.AUTH
59
+ assert exc_info.value.http_status == 401
60
+
61
+ def test_weak_passphrase(self):
62
+ """Passphrase < 8 characters raises KLICKD_E_WEAK_PASS."""
63
+ with pytest.raises(KlickdError) as exc_info:
64
+ save_klickd(TEST_PAYLOAD, "short")
65
+ assert exc_info.value.code == KlickdErrorCode.WEAK_PASS
66
+ assert exc_info.value.http_status == 422
67
+
68
+ def test_malformed_json(self):
69
+ """Non-JSON input raises KLICKD_E_FORMAT."""
70
+ with pytest.raises(KlickdError) as exc_info:
71
+ load_klickd(b"not-json", passphrase=PASSPHRASE)
72
+ assert exc_info.value.code == KlickdErrorCode.FORMAT
73
+
74
+ def test_missing_schema_version(self):
75
+ """Payload missing payload_schema_version raises KLICKD_E_SCHEMA."""
76
+ bad_payload = {"domain_schema_version": "1.0.0"}
77
+ with pytest.raises(KlickdError) as exc_info:
78
+ save_klickd(bad_payload, PASSPHRASE)
79
+ assert exc_info.value.code == KlickdErrorCode.SCHEMA
80
+
81
+ def test_legacy_kdf_requires_flag(self):
82
+ """PBKDF2 KDF envelope without legacy=True raises KLICKD_E_KDF."""
83
+ fake_envelope = json.dumps({
84
+ "klickd_version": "3.0.0",
85
+ "encrypted": True,
86
+ "domain": "education",
87
+ "created_at": "2025-01-01T00:00:00Z",
88
+ "kdf": {
89
+ "name": "pbkdf2-sha256",
90
+ "params": {"iterations": 600000},
91
+ "salt": "AAAAAAAAAAAAAAAAAAAAAA==",
92
+ },
93
+ "cipher": {"name": "AES-256-GCM", "iv": "AAAAAAAAAAAAAAAA"},
94
+ "ciphertext": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
95
+ }).encode("utf-8")
96
+
97
+ with pytest.raises(KlickdError) as exc_info:
98
+ load_klickd(fake_envelope, passphrase=PASSPHRASE)
99
+ assert exc_info.value.code == KlickdErrorCode.KDF
100
+
101
+ def test_unsupported_version(self):
102
+ """A v99 envelope raises KLICKD_E_VERSION."""
103
+ fake_envelope = json.dumps({
104
+ "klickd_version": "99.0.0",
105
+ "encrypted": True,
106
+ "domain": "education",
107
+ "created_at": "2025-01-01T00:00:00Z",
108
+ "kdf": {"name": "argon2id", "params": {"m": 65536, "t": 3, "p": 1}, "salt": "AAAAAAAAAAAAAAAAAAAAAA=="},
109
+ "cipher": {"name": "AES-256-GCM", "iv": "AAAAAAAAAAAAAAAA"},
110
+ "ciphertext": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
111
+ }).encode("utf-8")
112
+
113
+ with pytest.raises(KlickdError) as exc_info:
114
+ load_klickd(fake_envelope, passphrase=PASSPHRASE)
115
+ assert exc_info.value.code == KlickdErrorCode.VERSION
116
+
117
+ def test_each_save_produces_unique_ciphertext(self):
118
+ """Each call to save_klickd produces a fresh salt/IV (probabilistic encryption)."""
119
+ e1 = save_klickd(TEST_PAYLOAD, PASSPHRASE)
120
+ e2 = save_klickd(TEST_PAYLOAD, PASSPHRASE)
121
+ assert json.loads(e1)["kdf"]["salt"] != json.loads(e2)["kdf"]["salt"]
122
+ assert json.loads(e1)["cipher"]["iv"] != json.loads(e2)["cipher"]["iv"]
123
+ assert json.loads(e1)["ciphertext"] != json.loads(e2)["ciphertext"]
124
+
125
+ def test_custom_argon2id_params(self):
126
+ """Custom Argon2id params are stored in the envelope and round-trip correctly."""
127
+ custom_params = {"m": 32768, "t": 2, "p": 1}
128
+ envelope_bytes = save_klickd(TEST_PAYLOAD, PASSPHRASE, kdf_params=custom_params)
129
+ envelope = json.loads(envelope_bytes)
130
+ assert envelope["kdf"]["params"]["m"] == 32768
131
+ assert envelope["kdf"]["params"]["t"] == 2
132
+
133
+ payload = load_klickd(envelope_bytes, passphrase=PASSPHRASE)
134
+ assert payload["payload_schema_version"] == "3.0.0"