deeprem 0.3.0__py3-none-any.whl

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.
Files changed (47) hide show
  1. deeprem/__init__.py +20 -0
  2. deeprem/__main__.py +3 -0
  3. deeprem/_schema.py +26 -0
  4. deeprem/_util.py +166 -0
  5. deeprem/cli.py +210 -0
  6. deeprem/crypto.py +151 -0
  7. deeprem/dreaming.py +136 -0
  8. deeprem/dynamics.py +241 -0
  9. deeprem/engine.py +686 -0
  10. deeprem/errors.py +33 -0
  11. deeprem/memory.py +536 -0
  12. deeprem/models.py +61 -0
  13. deeprem/py.typed +0 -0
  14. deeprem/runtime_cli.py +119 -0
  15. deeprem/runtime_store.py +91 -0
  16. deeprem/schemas/approval-request-v1.schema.json +125 -0
  17. deeprem/schemas/approval-v1.schema.json +141 -0
  18. deeprem/schemas/autobiography-v1.schema.json +92 -0
  19. deeprem/schemas/checkpoint-v1.schema.json +36 -0
  20. deeprem/schemas/context-receipt-v1.schema.json +101 -0
  21. deeprem/schemas/dream-v1.schema.json +609 -0
  22. deeprem/schemas/event-v1.schema.json +768 -0
  23. deeprem/schemas/evidence-v1.schema.json +33 -0
  24. deeprem/schemas/fragment-v1.schema.json +51 -0
  25. deeprem/schemas/manifest-v1.schema.json +101 -0
  26. deeprem/schemas/output-hook-snapshot-v1.schema.json +147 -0
  27. deeprem/schemas/output-hook-v1.schema.json +131 -0
  28. deeprem/schemas/proposal-v1.schema.json +160 -0
  29. deeprem/schemas/relationship-v1.schema.json +25 -0
  30. deeprem/schemas/runtime-event-v1.schema.json +1266 -0
  31. deeprem/schemas/runtime-manifest-v1.schema.json +308 -0
  32. deeprem/schemas/runtime-policy-v1.schema.json +225 -0
  33. deeprem/schemas/subconscious-event-v1.schema.json +2309 -0
  34. deeprem/schemas/subconscious-manifest-v1.schema.json +786 -0
  35. deeprem/source.py +90 -0
  36. deeprem/store.py +224 -0
  37. deeprem/subconscious.py +925 -0
  38. deeprem/subconscious_cli.py +91 -0
  39. deeprem/subconscious_index.py +62 -0
  40. deeprem/subconscious_rules.py +186 -0
  41. deeprem/subconscious_store.py +37 -0
  42. deeprem-0.3.0.dist-info/METADATA +204 -0
  43. deeprem-0.3.0.dist-info/RECORD +47 -0
  44. deeprem-0.3.0.dist-info/WHEEL +5 -0
  45. deeprem-0.3.0.dist-info/entry_points.txt +4 -0
  46. deeprem-0.3.0.dist-info/licenses/LICENSE +21 -0
  47. deeprem-0.3.0.dist-info/top_level.txt +1 -0
deeprem/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ from .crypto import SigningKey, generate_encryption_key, sign_approval
2
+ from .errors import (ApprovalRequired, ConflictError, DeepRemError, EvidenceError,
3
+ IntegrityError, KeyRequired, NotFoundError, ValidationError)
4
+ from .memory import Memory
5
+ from .models import Hit, Record, Verification
6
+
7
+ __version__ = "0.3.0"
8
+ __all__ = ["Memory", "Record", "Hit", "Verification", "SigningKey", "generate_encryption_key", "sign_approval",
9
+ "DeepRemError", "ValidationError", "IntegrityError", "EvidenceError", "ApprovalRequired", "ConflictError",
10
+ "NotFoundError", "KeyRequired"]
11
+
12
+ from .dynamics import Policy
13
+ from .engine import Engine
14
+
15
+ __all__ += ["Engine", "Policy"]
16
+
17
+ from .subconscious import Subconscious
18
+ from .subconscious_rules import StreamWeights, SubconsciousPolicy
19
+
20
+ __all__ += ["Subconscious", "StreamWeights", "SubconsciousPolicy"]
deeprem/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
deeprem/_schema.py ADDED
@@ -0,0 +1,26 @@
1
+ """Versioned schemas are shipped as package resources and used at runtime."""
2
+ from functools import lru_cache
3
+ from importlib.resources import files
4
+ import json
5
+
6
+ from jsonschema import Draft202012Validator, FormatChecker
7
+ from jsonschema.exceptions import ValidationError as SchemaError
8
+
9
+ from ._util import canonical
10
+ from .errors import ValidationError
11
+
12
+
13
+ @lru_cache(maxsize=None)
14
+ def validator(name: str) -> Draft202012Validator:
15
+ schema = json.loads(files("deeprem").joinpath("schemas", name + "-v1.schema.json").read_text(encoding="utf-8"))
16
+ return Draft202012Validator(schema, format_checker=FormatChecker())
17
+
18
+
19
+ def validate(name: str, value) -> None:
20
+ canonical(value)
21
+ try:
22
+ validator(name).validate(value)
23
+ except SchemaError as exc:
24
+ location = ".".join(str(p) for p in exc.absolute_path) or "root"
25
+ # Do not include secret journal prose in an exception message.
26
+ raise ValidationError(f"invalid {name} at {location}: failed {exc.validator}") from exc
deeprem/_util.py ADDED
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import os
7
+ import re
8
+ import tempfile
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .errors import IntegrityError, ValidationError
14
+
15
+ MAX_BYTES = 16 * 1024 * 1024
16
+ ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$"
17
+
18
+
19
+ def json_value(value: Any, *, _seen: set[int] | None = None, _depth: int = 0) -> None:
20
+ if _depth > 64:
21
+ raise ValidationError("JSON nesting exceeds the v1 limit of 64")
22
+ if value is None or type(value) in (str, int, bool):
23
+ return
24
+ if type(value) is float and math.isfinite(value):
25
+ return
26
+ if type(value) not in (list, dict) or (type(value) is dict and not all(type(key) is str for key in value)):
27
+ raise ValidationError("values must be finite JSON types with string object keys")
28
+ seen = set() if _seen is None else _seen
29
+ identity = id(value)
30
+ if identity in seen:
31
+ raise ValidationError("cyclic objects are not JSON")
32
+ seen.add(identity)
33
+ try:
34
+ for item in (value.values() if isinstance(value, dict) else value):
35
+ json_value(item, _seen=seen, _depth=_depth + 1)
36
+ finally:
37
+ seen.remove(identity)
38
+
39
+
40
+ def canonical(value: Any) -> bytes:
41
+ json_value(value)
42
+ try:
43
+ return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("ascii")
44
+ except (ValueError, TypeError, RecursionError) as exc:
45
+ raise ValidationError("invalid JSON value") from exc
46
+
47
+
48
+ def sha256(data: bytes) -> str:
49
+ return hashlib.sha256(data).hexdigest()
50
+
51
+
52
+ def _pairs(pairs):
53
+ result = {}
54
+ for key, value in pairs:
55
+ if key in result:
56
+ raise ValidationError("duplicate JSON object key")
57
+ result[key] = value
58
+ return result
59
+
60
+
61
+ def loads(raw: bytes | str) -> Any:
62
+ if len(raw) > MAX_BYTES:
63
+ raise ValidationError("JSON document exceeds the 16 MiB v1 limit")
64
+ try:
65
+ value = json.loads(raw, object_pairs_hook=_pairs)
66
+ json_value(value)
67
+ return value
68
+ except (UnicodeError, ValueError, TypeError, RecursionError) as exc:
69
+ raise ValidationError("invalid JSON document") from exc
70
+
71
+
72
+ def now() -> datetime:
73
+ return datetime.now(timezone.utc)
74
+
75
+
76
+ def iso(value: datetime) -> str:
77
+ return value.astimezone(timezone.utc).isoformat(timespec="microseconds")
78
+
79
+
80
+ def timestamp(value: str) -> datetime:
81
+ if not isinstance(value, str):
82
+ raise ValidationError("timestamp must be an ISO8601 string with a timezone")
83
+ try:
84
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
85
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
86
+ raise ValueError("missing timezone")
87
+ return parsed.astimezone(timezone.utc)
88
+ except (ValueError, OverflowError) as exc:
89
+ raise ValidationError("timestamp must be ISO8601 with a timezone") from exc
90
+
91
+
92
+ def boundary(value: str | None, *, relative_to: datetime) -> datetime | None:
93
+ if value is None:
94
+ return None
95
+ if isinstance(value, str) and re.fullmatch(r"[1-9][0-9]{0,4}d", value):
96
+ return relative_to - timedelta(days=int(value[:-1]))
97
+ return timestamp(value)
98
+
99
+
100
+ def safe_id(value: str) -> str:
101
+ if not isinstance(value, str) or re.fullmatch(ID_PATTERN, value) is None:
102
+ raise ValidationError("unsafe or invalid entry id")
103
+ return value
104
+
105
+
106
+ def text(value: str, name: str, maximum: int = 2048) -> str:
107
+ if not isinstance(value, str) or not value.strip() or len(value) > maximum:
108
+ raise ValidationError(f"{name} must be nonblank text of at most {maximum} characters")
109
+ return value
110
+
111
+
112
+ def integer(value: int, name: str, minimum: int = 0, maximum: int = 10000) -> int:
113
+ if type(value) is not int or not minimum <= value <= maximum:
114
+ raise ValidationError(f"{name} must be an integer in [{minimum}, {maximum}]")
115
+ return value
116
+
117
+
118
+ def safe_path(path: Path, root: Path) -> Path:
119
+ if not path.is_relative_to(root):
120
+ raise IntegrityError("path escapes the configured root")
121
+ for item in (path, *path.parents):
122
+ if item == root:
123
+ break
124
+ if item.is_symlink():
125
+ raise IntegrityError("symlinks are not allowed within canonical storage")
126
+ if not path.resolve().is_relative_to(root):
127
+ raise IntegrityError("path escapes the configured root")
128
+ return path
129
+
130
+
131
+ def fsync_dir(path: Path) -> None:
132
+ if os.name == "posix":
133
+ fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
134
+ try:
135
+ os.fsync(fd)
136
+ finally:
137
+ os.close(fd)
138
+
139
+
140
+ def atomic_write(path: Path, data: bytes, *, replace: bool = False) -> None:
141
+ """Publish a fully synced file; never replace canonical history by default.
142
+
143
+ All canonical calls are serialized by an OS-held lock. Hard-link publication
144
+ also prevents overwriting a pre-existing destination after a crash.
145
+ """
146
+ fd, name = tempfile.mkstemp(prefix=".pending-", dir=path.parent)
147
+ temp = Path(name)
148
+ try:
149
+ with os.fdopen(fd, "wb") as handle:
150
+ handle.write(data)
151
+ handle.flush()
152
+ os.fsync(handle.fileno())
153
+ if replace:
154
+ os.replace(temp, path)
155
+ else:
156
+ os.link(temp, path)
157
+ temp.unlink()
158
+ fsync_dir(path.parent)
159
+ finally:
160
+ temp.unlink(missing_ok=True)
161
+
162
+
163
+ def read_bytes(path: Path) -> bytes:
164
+ if path.stat().st_size > MAX_BYTES:
165
+ raise IntegrityError("canonical file exceeds the 16 MiB v1 limit")
166
+ return path.read_bytes()
deeprem/cli.py ADDED
@@ -0,0 +1,210 @@
1
+ """JSON-first CLI; private keys are accepted only by file path, never inline."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from filelock import Timeout
10
+
11
+ from ._util import atomic_write, canonical, loads
12
+ from .crypto import SigningKey, generate_encryption_key, sign_approval
13
+ from .errors import DeepRemError, ValidationError
14
+ from .memory import Memory
15
+
16
+
17
+ def _read_json(path: str):
18
+ return loads(sys.stdin.read() if path == "-" else Path(path).read_bytes())
19
+
20
+
21
+ def _emit(value):
22
+ if hasattr(value, "to_dict"):
23
+ value = value.to_dict()
24
+ elif isinstance(value, list):
25
+ value = [item.to_dict() if hasattr(item, "to_dict") else item for item in value]
26
+ print(json.dumps(value, ensure_ascii=True, sort_keys=True, indent=2, allow_nan=False))
27
+
28
+
29
+ def parser() -> argparse.ArgumentParser:
30
+ p = argparse.ArgumentParser(prog="deeprem", description="Local memory. Use subconscious for autobiographical output hooks, runtime for the older Engine, or the legacy Memory commands below.")
31
+ p.add_argument("--root", default="memory")
32
+ p.add_argument("--journal", help="ghostjournal root; not needed for keygen, approve or verify --log-only")
33
+ p.add_argument("--agent", help="required on first initialization")
34
+ p.add_argument("--actor", help="audit label, not an authentication identity")
35
+ p.add_argument("--signing-key-file", help="external writer Ed25519 PEM file")
36
+ p.add_argument("--encryption-key-file", help="external Fernet key file")
37
+ p.add_argument("--reviewer-public-key-file", help="base64 Ed25519 public key, read at store creation")
38
+ p.add_argument("--checkpoint", help="trusted checkpoint kept outside the memory root")
39
+ sub = p.add_subparsers(dest="command", required=True)
40
+ sub.add_parser("init")
41
+ kg = sub.add_parser("keygen")
42
+ kg.add_argument("kind", choices=["signing", "encryption"])
43
+ kg.add_argument("--out", required=True, help="new external key file; signing also writes OUT.pub")
44
+ ap = sub.add_parser("approve", help="operator-only signing of a reviewed approval request")
45
+ ap.add_argument("--request", required=True)
46
+ ap.add_argument("--reviewer-key-file", required=True)
47
+ ap.add_argument("--out", required=True)
48
+ cp = sub.add_parser("consolidate")
49
+ cp.add_argument("--since", default="30d")
50
+ cp.add_argument("--until")
51
+ cp.add_argument("--min-entries", type=int, default=3)
52
+ cp.add_argument("--min-days", type=int, default=3)
53
+ cp.add_argument("--dimensions", nargs="+", choices=["theme", "focus", "tag"], default=["theme", "focus"])
54
+ pp = sub.add_parser("propose", help="read a proposal argument object, including supporting entry ids")
55
+ pp.add_argument("--json", default="-", help="file or '-' for stdin")
56
+ for command in ["commit", "reject", "deactivate", "resolve"]:
57
+ sp = sub.add_parser(command)
58
+ sp.add_argument("id")
59
+ sp.add_argument("--reason", required=True)
60
+ sp.add_argument("--approval", help="reviewer-signed approval JSON")
61
+ sp.add_argument("--client-key")
62
+ ev = sub.add_parser("add-evidence")
63
+ ev.add_argument("id")
64
+ ev.add_argument("--support", action="append", default=[])
65
+ ev.add_argument("--contradict", action="append", default=[])
66
+ ev.add_argument("--reason", required=True)
67
+ ev.add_argument("--client-key")
68
+ pa = sub.add_parser("prepare-approval")
69
+ pa.add_argument("action", choices=["commit", "reject", "deactivate", "resolve"])
70
+ pa.add_argument("id")
71
+ pa.add_argument("--reason", required=True)
72
+ pa.add_argument("--ttl-seconds", type=int, default=3600)
73
+ gp = sub.add_parser("get")
74
+ gp.add_argument("id")
75
+ lp = sub.add_parser("list")
76
+ lp.add_argument("--status")
77
+ lp.add_argument("--kind")
78
+ lp.add_argument("--limit", type=int, default=100)
79
+ rp = sub.add_parser("recall")
80
+ rp.add_argument("query")
81
+ rp.add_argument("-k", type=int, default=8)
82
+ rp.add_argument("--include-inactive", action="store_true")
83
+ pc = sub.add_parser("prompt-context")
84
+ pc.add_argument("query")
85
+ pc.add_argument("-k", type=int, default=6)
86
+ pc.add_argument("--max-chars", type=int, default=6000)
87
+ for cmd in ("identity", "open-threads", "seal-journal", "reindex"):
88
+ sub.add_parser(cmd)
89
+ ch = sub.add_parser("changes")
90
+ ch.add_argument("--since", default="90d")
91
+ ch.add_argument("--until")
92
+ dp = sub.add_parser("decay")
93
+ dp.add_argument("--as-of")
94
+ dp.add_argument("--threshold", type=float, default=0.1)
95
+ vp = sub.add_parser("verify")
96
+ vp.add_argument("--log-only", action="store_true", help="explicitly skip journal evidence verification")
97
+ ck = sub.add_parser("checkpoint")
98
+ ck.add_argument("--out", required=True, help="new path outside memory root; protect it with independent permissions")
99
+ return p
100
+
101
+
102
+ def _outside(path: str, root: Path) -> Path:
103
+ result = Path(path).expanduser().resolve()
104
+ if result.is_relative_to(root):
105
+ raise ValidationError("private keys and trusted checkpoints must be stored outside the memory root")
106
+ return result
107
+
108
+
109
+ def _run(args) -> int:
110
+ root = Path(args.root).expanduser().resolve()
111
+ if args.command == "keygen":
112
+ out = _outside(args.out, root)
113
+ if args.kind == "signing":
114
+ pub_path = Path(str(out) + ".pub")
115
+ if out.exists() or pub_path.exists():
116
+ raise ValidationError("key output already exists")
117
+ key = SigningKey.generate()
118
+ key.save(out)
119
+ atomic_write(pub_path, (key.public_key + "\n").encode("ascii"))
120
+ _emit({"private_key_file": str(out), "public_key_file": str(pub_path), "public_key": key.public_key})
121
+ else:
122
+ out.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
123
+ atomic_write(out, generate_encryption_key() + b"\n")
124
+ _emit({"encryption_key_file": str(out)})
125
+ return 0
126
+ if args.command == "approve":
127
+ key = SigningKey.load(_outside(args.reviewer_key_file, root))
128
+ approval = sign_approval(_read_json(args.request), key)
129
+ out = Path(args.out)
130
+ out.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
131
+ atomic_write(out, canonical(approval) + b"\n")
132
+ _emit({"approval_file": str(out)})
133
+ return 0
134
+ log_only = args.command == "verify" and args.log_only
135
+ if args.journal is None and not log_only:
136
+ raise ValidationError("--journal is required for memory operations")
137
+ signer = SigningKey.load(_outside(args.signing_key_file, root)) if args.signing_key_file else None
138
+ key = _outside(args.encryption_key_file, root).read_bytes().strip() if args.encryption_key_file else None
139
+ reviewer = Path(args.reviewer_public_key_file).read_text(encoding="ascii").strip() if args.reviewer_public_key_file else None
140
+ checkpoint = _read_json(str(_outside(args.checkpoint, root))) if args.checkpoint else None
141
+ m = Memory(args.root, journal=None if log_only else args.journal, agent=args.agent, signing_key=signer,
142
+ encryption_key=key, reviewer_public_key=reviewer, checkpoint=checkpoint)
143
+ command = args.command
144
+ if command == "init":
145
+ result = {"root": str(m.root), "agent": m.agent, "verification": m.verify().to_dict()}
146
+ elif command == "propose":
147
+ data = _read_json(args.json)
148
+ allowed = {"statement", "supporting_entries", "contradicting_entries", "kind", "theme", "tags", "protected", "supersedes", "half_life_days", "client_key"}
149
+ if not isinstance(data, dict) or set(data) - allowed or "statement" not in data:
150
+ raise ValidationError("proposal JSON has missing or unknown argument fields")
151
+ result = m.propose(**data, actor=args.actor)
152
+ elif command == "consolidate":
153
+ result = m.consolidate(since=args.since, until=args.until, min_entries=args.min_entries, min_days=args.min_days,
154
+ dimensions=tuple(args.dimensions), actor=args.actor)
155
+ elif command in {"commit", "reject", "deactivate", "resolve"}:
156
+ result = getattr(m, command)(args.id, reason=args.reason, approval=_read_json(args.approval) if args.approval else None,
157
+ client_key=args.client_key, actor=args.actor)
158
+ elif command == "add-evidence":
159
+ result = m.add_evidence(args.id, supporting_entries=args.support, contradicting_entries=args.contradict,
160
+ reason=args.reason, client_key=args.client_key, actor=args.actor)
161
+ elif command == "prepare-approval":
162
+ result = m.prepare_approval(args.action, args.id, reason=args.reason, ttl_seconds=args.ttl_seconds)
163
+ elif command == "get":
164
+ result = m.get(args.id)
165
+ elif command == "list":
166
+ result = m.list(status=args.status, kind=args.kind, limit=args.limit)
167
+ elif command == "recall":
168
+ result = m.recall(args.query, k=args.k, include_inactive=args.include_inactive)
169
+ elif command == "prompt-context":
170
+ print(m.prompt_context(args.query, k=args.k, max_chars=args.max_chars))
171
+ return 0
172
+ elif command == "identity":
173
+ result = m.identity()
174
+ elif command == "open-threads":
175
+ result = m.open_threads()
176
+ elif command == "changes":
177
+ result = m.changes(since=args.since, until=args.until)
178
+ elif command == "decay":
179
+ result = m.decay(as_of=args.as_of, threshold=args.threshold, actor=args.actor)
180
+ elif command == "seal-journal":
181
+ result = {"newly_sealed": m.seal_journal(actor=args.actor)}
182
+ elif command == "verify":
183
+ result = m.verify(check_journal=not log_only)
184
+ elif command == "reindex":
185
+ result = m.reindex()
186
+ elif command == "checkpoint":
187
+ out = _outside(args.out, root)
188
+ out.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
189
+ result = m.checkpoint()
190
+ atomic_write(out, canonical(result) + b"\n")
191
+ else:
192
+ raise ValidationError("unknown command")
193
+ _emit(result)
194
+ return 0
195
+
196
+
197
+ def main(argv: list[str] | None = None) -> int:
198
+ argv = list(sys.argv[1:] if argv is None else argv)
199
+ if argv[:1] == ["subconscious"]:
200
+ from .subconscious_cli import main as subconscious_main
201
+ return subconscious_main(argv[1:])
202
+ if argv[:1] == ["runtime"]:
203
+ from .runtime_cli import main as runtime_main
204
+ return runtime_main(argv[1:])
205
+ args = parser().parse_args(argv)
206
+ try:
207
+ return _run(args)
208
+ except (DeepRemError, OSError, Timeout) as exc:
209
+ print(json.dumps({"error": type(exc).__name__, "message": str(exc)}, ensure_ascii=True), file=sys.stderr)
210
+ return 2
deeprem/crypto.py ADDED
@@ -0,0 +1,151 @@
1
+ """Optional cryptography adapter. No custom ciphers, key derivation, or nonce code."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ._schema import validate
9
+ from ._util import atomic_write, canonical
10
+ from .errors import ApprovalRequired, IntegrityError, KeyRequired, ValidationError
11
+
12
+ APPROVAL_DOMAIN = b"deeprem:review:v1\x00"
13
+ EVENT_DOMAIN = b"deeprem:event:v1\x00"
14
+
15
+
16
+ def _imports():
17
+ try:
18
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
19
+ from cryptography.hazmat.primitives import serialization
20
+ return Ed25519PrivateKey, Ed25519PublicKey, serialization
21
+ except ImportError as exc:
22
+ raise KeyRequired("install deeprem[crypto] to use signing, review gates or encryption") from exc
23
+
24
+
25
+ def b64(value: bytes) -> str:
26
+ return base64.urlsafe_b64encode(value).decode("ascii")
27
+
28
+
29
+ def unb64(value: str) -> bytes:
30
+ try:
31
+ return base64.b64decode(value.encode("ascii"), altchars=b"-_", validate=True)
32
+ except (ValueError, UnicodeError, AttributeError) as exc:
33
+ raise ValidationError("invalid base64 key or signature") from exc
34
+
35
+
36
+ class SigningKey:
37
+ """Ed25519 private key. Keep reviewer keys outside the agent's OS identity."""
38
+
39
+ def __init__(self, raw: bytes):
40
+ private, _, _ = _imports()
41
+ try:
42
+ self._key = private.from_private_bytes(raw)
43
+ except (TypeError, ValueError) as exc:
44
+ raise ValidationError("Ed25519 private keys must be 32 bytes") from exc
45
+
46
+ @classmethod
47
+ def generate(cls) -> SigningKey:
48
+ private, _, serialization = _imports()
49
+ raw = private.generate().private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
50
+ return cls(raw)
51
+
52
+ @classmethod
53
+ def load(cls, path: str | Path) -> SigningKey:
54
+ private, _, serialization = _imports()
55
+ try:
56
+ key = serialization.load_pem_private_key(Path(path).read_bytes(), password=None)
57
+ if not isinstance(key, private):
58
+ raise ValueError("not Ed25519")
59
+ return cls(key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()))
60
+ except (TypeError, ValueError) as exc:
61
+ raise ValidationError("expected an unencrypted Ed25519 PKCS8 PEM key") from exc
62
+
63
+ @property
64
+ def public_key(self) -> str:
65
+ _, _, serialization = _imports()
66
+ return b64(self._key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw))
67
+
68
+ def sign(self, data: bytes) -> str:
69
+ return b64(self._key.sign(data))
70
+
71
+ def save(self, path: str | Path) -> None:
72
+ """Write a NEW 0600 PEM key file; never print private material or overwrite."""
73
+ _, _, serialization = _imports()
74
+ path = Path(path)
75
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
76
+ atomic_write(path, self._key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
77
+
78
+
79
+ def verify_signature(public_key: str, signature: str, data: bytes) -> None:
80
+ _, public, _ = _imports()
81
+ try:
82
+ public.from_public_bytes(unb64(public_key)).verify(unb64(signature), data)
83
+ except Exception as exc:
84
+ # Import errors are handled before this block, invalid signatures fail closed.
85
+ raise IntegrityError("signature verification failed") from exc
86
+
87
+
88
+ def validate_public_key(value: str) -> None:
89
+ _, public, _ = _imports()
90
+ try:
91
+ public.from_public_bytes(unb64(value))
92
+ except (ValueError, TypeError) as exc:
93
+ raise ValidationError("invalid Ed25519 public key") from exc
94
+
95
+
96
+ def sign_approval(request: dict[str, Any], reviewer: SigningKey) -> dict[str, Any]:
97
+ """Run in an operator-controlled process, after reviewing the request/evidence."""
98
+ validate("approval-request", request)
99
+ return {"request": request, "signature": reviewer.sign(APPROVAL_DOMAIN + canonical(request))}
100
+
101
+
102
+ def verify_approval_signature(approval: dict, public_key: str | None) -> None:
103
+ if public_key is None:
104
+ raise ApprovalRequired("this store has no reviewer; protected transitions are disabled")
105
+ try:
106
+ validate("approval", approval)
107
+ verify_signature(public_key, approval["signature"], APPROVAL_DOMAIN + canonical(approval["request"]))
108
+ except (IntegrityError, ValidationError) as exc:
109
+ raise ApprovalRequired("independent reviewer signature is missing or invalid") from exc
110
+
111
+
112
+ def generate_encryption_key() -> bytes:
113
+ try:
114
+ from cryptography.fernet import Fernet
115
+ return Fernet.generate_key()
116
+ except ImportError as exc:
117
+ raise KeyRequired("install deeprem[crypto] for encryption") from exc
118
+
119
+
120
+ class Codec:
121
+ def __init__(self, encrypted: bool, key: bytes | None):
122
+ self.encrypted = encrypted
123
+ self._cipher = None
124
+ if encrypted:
125
+ if key is None:
126
+ raise KeyRequired("this store requires its external Fernet encryption key")
127
+ try:
128
+ from cryptography.fernet import Fernet
129
+ self._cipher = Fernet(key)
130
+ except ImportError as exc:
131
+ raise KeyRequired("install deeprem[crypto] for encryption") from exc
132
+ except (TypeError, ValueError) as exc:
133
+ raise ValidationError("invalid Fernet key; use generate_encryption_key()") from exc
134
+ elif key is not None:
135
+ raise ValidationError("cannot silently change an existing store's encryption mode")
136
+
137
+ @property
138
+ def suffix(self) -> str:
139
+ return ".fernet" if self.encrypted else ".json"
140
+
141
+ def encode(self, data: bytes) -> bytes:
142
+ return self._cipher.encrypt(data) if self._cipher else data + b"\n"
143
+
144
+ def decode(self, data: bytes) -> bytes:
145
+ if self._cipher is None:
146
+ return data
147
+ from cryptography.fernet import InvalidToken
148
+ try:
149
+ return self._cipher.decrypt(data)
150
+ except InvalidToken as exc:
151
+ raise IntegrityError("encrypted event authentication failed (wrong key or changed ciphertext)") from exc