compartment 2.1__tar.gz → 2.3__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.
Files changed (66) hide show
  1. {compartment-2.1 → compartment-2.3}/PKG-INFO +1 -1
  2. {compartment-2.1 → compartment-2.3}/pyproject.toml +1 -1
  3. {compartment-2.1 → compartment-2.3}/src/compartment/__init__.py +1 -1
  4. compartment-2.3/src/compartment/audit.py +103 -0
  5. {compartment-2.1 → compartment-2.3}/src/compartment/cli.py +21 -0
  6. {compartment-2.1 → compartment-2.3}/src/compartment/crypto.py +87 -14
  7. {compartment-2.1 → compartment-2.3}/src/compartment/menubar.py +24 -4
  8. {compartment-2.1 → compartment-2.3}/src/compartment/packs.py +3 -3
  9. {compartment-2.1 → compartment-2.3}/src/compartment/session.py +25 -12
  10. {compartment-2.1 → compartment-2.3}/src/compartment/store.py +29 -3
  11. {compartment-2.1 → compartment-2.3}/src/compartment/vault.py +81 -31
  12. {compartment-2.1 → compartment-2.3}/src/compartment/vaultfile.py +7 -5
  13. compartment-2.3/src/compartment/wire.py +130 -0
  14. {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/PKG-INFO +1 -1
  15. {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/SOURCES.txt +2 -0
  16. compartment-2.3/tests/test_audit_chain.py +123 -0
  17. compartment-2.3/tests/test_ondisk_format.py +213 -0
  18. compartment-2.1/src/compartment/audit.py +0 -54
  19. compartment-2.1/tests/test_ondisk_format.py +0 -97
  20. {compartment-2.1 → compartment-2.3}/README.md +0 -0
  21. {compartment-2.1 → compartment-2.3}/setup.cfg +0 -0
  22. {compartment-2.1 → compartment-2.3}/src/compartment/acl.py +0 -0
  23. {compartment-2.1 → compartment-2.3}/src/compartment/bench.py +0 -0
  24. {compartment-2.1 → compartment-2.3}/src/compartment/claude_hooks.py +0 -0
  25. {compartment-2.1 → compartment-2.3}/src/compartment/claude_memory.py +0 -0
  26. {compartment-2.1 → compartment-2.3}/src/compartment/dash.py +0 -0
  27. {compartment-2.1 → compartment-2.3}/src/compartment/data/hermes-plugin/__init__.py +0 -0
  28. {compartment-2.1 → compartment-2.3}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
  29. {compartment-2.1 → compartment-2.3}/src/compartment/data/menubar.png +0 -0
  30. {compartment-2.1 → compartment-2.3}/src/compartment/data/menubar@2x.png +0 -0
  31. {compartment-2.1 → compartment-2.3}/src/compartment/data/starter.mpack +0 -0
  32. {compartment-2.1 → compartment-2.3}/src/compartment/data/tray.ico +0 -0
  33. {compartment-2.1 → compartment-2.3}/src/compartment/embed.py +0 -0
  34. {compartment-2.1 → compartment-2.3}/src/compartment/home.py +0 -0
  35. {compartment-2.1 → compartment-2.3}/src/compartment/longmemeval.py +0 -0
  36. {compartment-2.1 → compartment-2.3}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
  37. {compartment-2.1 → compartment-2.3}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
  38. {compartment-2.1 → compartment-2.3}/src/compartment/offline_guard.py +0 -0
  39. {compartment-2.1 → compartment-2.3}/src/compartment/platforms.py +0 -0
  40. {compartment-2.1 → compartment-2.3}/src/compartment/salience.py +0 -0
  41. {compartment-2.1 → compartment-2.3}/src/compartment/selftest.py +0 -0
  42. {compartment-2.1 → compartment-2.3}/src/compartment/server.py +0 -0
  43. {compartment-2.1 → compartment-2.3}/src/compartment/systray.py +0 -0
  44. {compartment-2.1 → compartment-2.3}/src/compartment/vindex.py +0 -0
  45. {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/dependency_links.txt +0 -0
  46. {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/entry_points.txt +0 -0
  47. {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/requires.txt +0 -0
  48. {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/top_level.txt +0 -0
  49. {compartment-2.1 → compartment-2.3}/tests/test_acl_audit_packs.py +0 -0
  50. {compartment-2.1 → compartment-2.3}/tests/test_claude_hooks_and_integrate.py +0 -0
  51. {compartment-2.1 → compartment-2.3}/tests/test_claude_memory_and_recent.py +0 -0
  52. {compartment-2.1 → compartment-2.3}/tests/test_crypto.py +0 -0
  53. {compartment-2.1 → compartment-2.3}/tests/test_dash.py +0 -0
  54. {compartment-2.1 → compartment-2.3}/tests/test_instructions.py +0 -0
  55. {compartment-2.1 → compartment-2.3}/tests/test_macos_bundle.py +0 -0
  56. {compartment-2.1 → compartment-2.3}/tests/test_menubar.py +0 -0
  57. {compartment-2.1 → compartment-2.3}/tests/test_offline_selftest_bench.py +0 -0
  58. {compartment-2.1 → compartment-2.3}/tests/test_platforms.py +0 -0
  59. {compartment-2.1 → compartment-2.3}/tests/test_relations.py +0 -0
  60. {compartment-2.1 → compartment-2.3}/tests/test_salience.py +0 -0
  61. {compartment-2.1 → compartment-2.3}/tests/test_session_lock.py +0 -0
  62. {compartment-2.1 → compartment-2.3}/tests/test_starter_packs.py +0 -0
  63. {compartment-2.1 → compartment-2.3}/tests/test_systray.py +0 -0
  64. {compartment-2.1 → compartment-2.3}/tests/test_tamper.py +0 -0
  65. {compartment-2.1 → compartment-2.3}/tests/test_twofa.py +0 -0
  66. {compartment-2.1 → compartment-2.3}/tests/test_vault_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 2.1
3
+ Version: 2.3
4
4
  Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
5
5
  Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
6
6
  Project-URL: Source, https://github.com/MaxFreedomPollard/Compartment
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compartment"
7
- version = "2.1"
7
+ version = "2.3"
8
8
  description = "Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  """Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
2
2
 
3
- __version__ = "2.1"
3
+ __version__ = "2.3"
4
4
 
5
5
  from . import offline_guard as _og
6
6
 
@@ -0,0 +1,103 @@
1
+ """Hash-chained, tamper-evident audit log (stored inside the sealed payload).
2
+
3
+ Each entry's hash covers the previous entry's hash, so any edit, deletion,
4
+ or reordering of history breaks the chain at a detectable point.
5
+ A failure to write the audit entry fails the operation (fail-fast), never
6
+ the other way around.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import time
13
+
14
+ GENESIS = "GENESIS"
15
+
16
+
17
+ def _entry_hash(prev_hash: str, ts: float, caller: str, op: str, detail: str) -> str:
18
+ body = json.dumps(
19
+ {"prev": prev_hash, "ts": ts, "caller": caller, "op": op, "detail": detail},
20
+ sort_keys=True, separators=(",", ":"),
21
+ ).encode()
22
+ return hashlib.sha256(body).hexdigest()
23
+
24
+
25
+ def append(conn, caller: str, op: str, detail: str, ts: float | None = None) -> str:
26
+ """Add an entry, linked to whatever is currently at the head.
27
+
28
+ `ts` is only passed when replaying a journalled operation, so the entry
29
+ keeps the time it actually happened. It is still linked to the head as it
30
+ stands at replay time, never to the head the original writer saw: that
31
+ head may never have reached disk (read operations are audited in RAM and
32
+ persist only on save), and chaining to an entry nobody else has is what
33
+ leaves a dangling link no future verify can resolve."""
34
+ row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
35
+ prev = row["hash"] if row else GENESIS
36
+ ts = time.time() if ts is None else ts
37
+ h = _entry_hash(prev, ts, caller, op, detail)
38
+ conn.execute(
39
+ "INSERT INTO audit (ts, caller, op, detail, prev_hash, hash) VALUES (?,?,?,?,?,?)",
40
+ (ts, caller, op, detail, prev, h),
41
+ )
42
+ return h
43
+
44
+
45
+ def head(conn) -> str:
46
+ row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
47
+ return row["hash"] if row else GENESIS
48
+
49
+
50
+ def verify(conn) -> tuple[bool, int, str]:
51
+ """Walk the chain. Returns (ok, entries_checked, message)."""
52
+ prev = GENESIS
53
+ n = 0
54
+ for row in conn.execute("SELECT * FROM audit ORDER BY seq"):
55
+ if row["prev_hash"] != prev:
56
+ return False, n, f"chain break at seq {row['seq']}: prev_hash mismatch"
57
+ want = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
58
+ if row["hash"] != want:
59
+ return False, n, f"chain break at seq {row['seq']}: entry hash mismatch"
60
+ prev = row["hash"]
61
+ n += 1
62
+ return True, n, f"audit chain intact ({n} entries)"
63
+
64
+
65
+ def relink(conn) -> tuple[int, int | None]:
66
+ """Re-link entries whose prev_hash points at something not in the log.
67
+
68
+ Only for repairing damage done by builds before this one, which replayed a
69
+ journalled entry with the head its original writer saw rather than the head
70
+ the log actually has. The result was a permanent dangling link: verify
71
+ stopped at it and never reported anything after it again.
72
+
73
+ What this does NOT do is paper over an edit. Every entry's own hash covers
74
+ its content, so content tampering is caught by the self-hash check and this
75
+ refuses to touch a log that fails it. Only the links are rebuilt, and only
76
+ forward from the first break. Nothing is deleted, reordered or reworded:
77
+ ts, caller, op and detail are exactly what they were.
78
+
79
+ Returns (entries relinked, seq of the first break) - (0, None) if intact."""
80
+ rows = conn.execute("SELECT * FROM audit ORDER BY seq").fetchall()
81
+ for row in rows:
82
+ want = _entry_hash(row["prev_hash"], row["ts"], row["caller"], row["op"],
83
+ row["detail"])
84
+ if row["hash"] != want:
85
+ raise ValueError(
86
+ f"audit entry seq {row['seq']} does not hash to its own content. "
87
+ "That is content tampering, not a dangling link, and relinking "
88
+ "would destroy the evidence. Refusing.")
89
+
90
+ prev, first, changed = GENESIS, None, 0
91
+ for row in rows:
92
+ if row["prev_hash"] != prev:
93
+ if first is None:
94
+ first = row["seq"]
95
+ if first is not None: # rewrite everything after it
96
+ h = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
97
+ conn.execute("UPDATE audit SET prev_hash = ?, hash = ? WHERE seq = ?",
98
+ (prev, h, row["seq"]))
99
+ changed += 1
100
+ prev = h
101
+ else:
102
+ prev = row["hash"]
103
+ return changed, first
@@ -468,6 +468,25 @@ def cmd_audit(args) -> None:
468
468
  sys.exit(2)
469
469
 
470
470
 
471
+ def cmd_audit_repair(args) -> None:
472
+ v = _open_vault(args)
473
+ try:
474
+ changed, first = audit.relink(v.db.conn)
475
+ except ValueError as exc:
476
+ _die(str(exc))
477
+ if not changed:
478
+ _print({"ok": True, "relinked": 0, "message": "audit chain already intact"})
479
+ return
480
+ v._audit_and_capture("user", "audit-repair",
481
+ f"relinked {changed} entries from seq {first}")
482
+ v.save()
483
+ ok, n, msg = audit.verify(v.db.conn)
484
+ _print({"ok": ok, "relinked": changed, "first_break_at_seq": first,
485
+ "entries": n, "message": msg, "head": audit.head(v.db.conn)})
486
+ if not ok:
487
+ sys.exit(2)
488
+
489
+
471
490
  def cmd_verify(args) -> None:
472
491
  loaded = read_vault_file(args.vault) # structure + format checks
473
492
  out = {"vault": args.vault, "format": "ok",
@@ -1165,6 +1184,8 @@ def main(argv: list[str] | None = None) -> None:
1165
1184
  pa_sub = pa.add_subparsers(dest="audit_cmd", required=True)
1166
1185
  p = pa_sub.add_parser("verify", help="verify the hash chain")
1167
1186
  p.set_defaults(fn=cmd_audit)
1187
+ p = pa_sub.add_parser("repair", help="re-link a chain broken by an older build")
1188
+ p.set_defaults(fn=cmd_audit_repair)
1168
1189
 
1169
1190
  p = sub.add_parser("verify", help="check vault structure + signed manifest")
1170
1191
  p.set_defaults(fn=cmd_verify)
@@ -19,6 +19,8 @@ from nacl.bindings import (
19
19
  crypto_aead_xchacha20poly1305_ietf_encrypt,
20
20
  )
21
21
 
22
+ from . import wire
23
+
22
24
  KEY_LEN = 32
23
25
  NONCE_LEN = 24
24
26
 
@@ -81,6 +83,35 @@ def unseal(key: bytes, blob: bytes, aad: bytes = b"") -> bytes:
81
83
  ) from exc
82
84
 
83
85
 
86
+ def unseal_which(key: bytes, blob: bytes, aad: bytes,
87
+ legacy: tuple[bytes, ...] = ()) -> tuple[bytes, bytes]:
88
+ """unseal(), accepting associated data written by an older build.
89
+
90
+ Returns (plaintext, the aad that actually authenticated), so a caller can
91
+ tell a migrated blob from one still carrying a legacy label and rewrite it.
92
+
93
+ The current spelling is tried first, so anything already migrated costs
94
+ exactly one AEAD open. Legacy spellings are tried in order only after that
95
+ fails, which is what lets a pre-rename vault open at all. If none of them
96
+ authenticate the data really is tampered with, or the key really is wrong,
97
+ and the original error is raised."""
98
+ try:
99
+ return unseal(key, blob, aad=aad), aad
100
+ except TamperError:
101
+ for old in legacy:
102
+ try:
103
+ return unseal(key, blob, aad=old), old
104
+ except TamperError:
105
+ continue
106
+ raise
107
+
108
+
109
+ def unseal_any(key: bytes, blob: bytes, aad: bytes,
110
+ legacy: tuple[bytes, ...] = ()) -> bytes:
111
+ """unseal_which() when the caller does not care which label was used."""
112
+ return unseal_which(key, blob, aad, legacy)[0]
113
+
114
+
84
115
  def wipe(buf: bytearray) -> None:
85
116
  """Best-effort zeroization (Python cannot guarantee no copies exist)."""
86
117
  for i in range(len(buf)):
@@ -99,7 +130,7 @@ def make_passphrase_slot(master_key: bytes, passphrase: str, kdf: dict | None =
99
130
  "type": "passphrase",
100
131
  "kdf": kdf,
101
132
  "salt": salt.hex(),
102
- "wrapped": seal(wrap_key, master_key, aad=b"engram-keyslot").hex(),
133
+ "wrapped": seal(wrap_key, master_key, aad=wire.KEYSLOT).hex(),
103
134
  }
104
135
 
105
136
 
@@ -118,7 +149,7 @@ def make_recovery_slot(master_key: bytes, kdf: dict | None = None) -> tuple[dict
118
149
  # Domain separator between the knowledge factor (passphrase) and the
119
150
  # possession factor (keyfile bytes) before the KDF. Both factors feed
120
151
  # Argon2id, so two-factor unlock is enforced by arithmetic, not policy.
121
- KEYFILE_SEP = b"\x1f engram-2fa \x1f"
152
+ KEYFILE_SEP = wire.KEYFILE_SEP
122
153
  KEYFILE_LEN = 32
123
154
 
124
155
 
@@ -137,19 +168,57 @@ def make_keyfile_slot(master_key: bytes, passphrase: str, keyfile: bytes,
137
168
  "kdf": kdf,
138
169
  "salt": salt.hex(),
139
170
  "keyfile_id": sha256(keyfile)[:16], # UX only: detect the WRONG file
140
- "wrapped": seal(wrap_key, master_key, aad=b"engram-keyslot").hex(),
171
+ "wrapped": seal(wrap_key, master_key, aad=wire.KEYSLOT).hex(),
141
172
  }
142
173
 
143
174
 
144
- def open_slot(slot: dict, secret: str, keyfile: bytes | None = None) -> bytes:
145
- """Unwrap the master key from one slot."""
175
+ def _slot_secrets(slot: dict, secret: str, keyfile: bytes | None) -> list[bytes]:
176
+ """The KDF inputs worth trying for this slot, current spelling first.
177
+
178
+ Only a two-factor slot has more than one. Its separator is a KDF input
179
+ rather than associated data, so an older separator derives a different
180
+ wrap key and can only be ruled out by deriving again."""
146
181
  raw = secret.encode("utf-8")
147
- if slot["type"] == "passphrase+keyfile":
148
- if keyfile is None:
149
- raise CryptoError("This keyslot requires a keyfile")
150
- raw = raw + KEYFILE_SEP + keyfile
151
- wrap_key = derive_key(raw, bytes.fromhex(slot["salt"]), slot["kdf"])
152
- return unseal(wrap_key, bytes.fromhex(slot["wrapped"]), aad=b"engram-keyslot")
182
+ if slot["type"] != "passphrase+keyfile":
183
+ return [raw]
184
+ if keyfile is None:
185
+ raise CryptoError("This keyslot requires a keyfile")
186
+ return [raw + sep + keyfile
187
+ for sep in (wire.KEYFILE_SEP, *wire.KEYFILE_SEP_LEGACY)]
188
+
189
+
190
+ def open_slot(slot: dict, secret: str, keyfile: bytes | None = None) -> bytes:
191
+ """Unwrap the master key from one slot.
192
+
193
+ A slot written before 2.2 carries the legacy associated data, and a
194
+ two-factor one the legacy keyfile separator. Both still open. The slot is
195
+ then rewritten in place under the current spelling, so it migrates the
196
+ first time its credential is used; the caller persists that by saving the
197
+ header. Re-sealing only happens when something was actually legacy, or
198
+ every unlock would dirty the header and force a needless vault rewrite."""
199
+ salt, kdf = bytes.fromhex(slot["salt"]), slot["kdf"]
200
+ wrapped = bytes.fromhex(slot["wrapped"])
201
+ failure: TamperError | None = None
202
+ for i, raw in enumerate(_slot_secrets(slot, secret, keyfile)):
203
+ wrap_key = derive_key(raw, salt, kdf)
204
+ try:
205
+ master, used = unseal_which(wrap_key, wrapped, wire.KEYSLOT,
206
+ wire.KEYSLOT_LEGACY)
207
+ except TamperError as exc:
208
+ failure = exc
209
+ continue
210
+ if i:
211
+ # legacy separator: the wrap key itself is stale, so the slot has
212
+ # to be built again from scratch (a second Argon2id pass, once).
213
+ fresh = make_keyfile_slot(master, secret, keyfile, kdf)
214
+ fresh["type"] = slot["type"]
215
+ slot.clear()
216
+ slot.update(fresh)
217
+ elif used != wire.KEYSLOT:
218
+ # same wrap key, only the associated data moves. Free.
219
+ slot["wrapped"] = seal(wrap_key, master, aad=wire.KEYSLOT).hex()
220
+ return master
221
+ raise failure if failure is not None else TamperError("Keyslot did not open")
153
222
 
154
223
 
155
224
  def unwrap_master(keyslots: list[dict], secret: str,
@@ -190,16 +259,20 @@ def normalize_recovery(text: str) -> str:
190
259
  # Per-record keys → crypto-shred
191
260
  # ---------------------------------------------------------------------------
192
261
 
262
+ def wrap_record_key(master_key: bytes, record_id: str, record_key: bytes) -> bytes:
263
+ aad, _ = wire.record_key(record_id)
264
+ return seal(master_key, record_key, aad=aad)
265
+
266
+
193
267
  def new_record_key(master_key: bytes, record_id: str) -> tuple[bytes, bytes]:
194
268
  """Returns (record_key, wrapped_record_key). Destroying the wrapped key
195
269
  makes the record's ciphertext permanently undecryptable (crypto-shred)."""
196
270
  rk = new_key()
197
- wrapped = seal(master_key, rk, aad=b"engram-record:" + record_id.encode())
198
- return rk, wrapped
271
+ return rk, wrap_record_key(master_key, record_id, rk)
199
272
 
200
273
 
201
274
  def unwrap_record_key(master_key: bytes, record_id: str, wrapped: bytes) -> bytes:
202
- return unseal(master_key, wrapped, aad=b"engram-record:" + record_id.encode())
275
+ return unseal_any(master_key, wrapped, *wire.record_key(record_id))
203
276
 
204
277
 
205
278
  # 256 short, common, distinct words for recovery phrases (16 words = 128 bits).
@@ -397,6 +397,7 @@ def run(vault: str | None = None, show: bool = False,
397
397
  NSFont, NSImage, NSMakeRect, NSMenu, NSMenuItem,
398
398
  NSPanel, NSPopover, NSScreen, NSSegmentedControl,
399
399
  NSClipView, NSScrollView,
400
+ NSBitmapImageRep, NSGraphicsContext,
400
401
  NSSecureTextField, NSStackView, NSStatusBar,
401
402
  NSSwitch, NSTextField,
402
403
  NSButton, NSView, NSViewController,
@@ -852,14 +853,33 @@ def run(vault: str | None = None, show: bool = False,
852
853
  _d("controller ready")
853
854
 
854
855
  if render_to:
855
- # app.run() normally does this; without it the text system is not up
856
- # and the snapshot comes out with controls but no glyphs at all.
856
+ # app.run() normally does this; without it the text system is not up.
857
857
  app.finishLaunching()
858
858
  body = ctrl.buildBody()
859
859
  body.layoutSubtreeIfNeeded()
860
860
  bounds = body.bounds()
861
- rep = body.bitmapImageRepForCachingDisplayInRect_(bounds)
862
- body.cacheDisplayInRect_toBitmapImageRep_(bounds, rep)
861
+ # Render through PDF, not cacheDisplayInRect. Caching a view that
862
+ # belongs to no window gives you the switches and the segmented
863
+ # control and not one glyph of text, because there is no window
864
+ # backing store for the text system to lay glyphs into. Ordering an
865
+ # offscreen window in does not help either: it draws as an inactive
866
+ # window, so even the controls lose their tint. The PDF path records
867
+ # glyphs vectorially with their fonts embedded and needs no window at
868
+ # all, so it captures the panel exactly as designed. Rasterise at 2x
869
+ # for a Retina-sharp asset.
870
+ pdf = body.dataWithPDFInsideRect_(bounds)
871
+ w, h = bounds.size.width, bounds.size.height
872
+ image = NSImage.alloc().initWithData_(pdf)
873
+ rep = (NSBitmapImageRep.alloc()
874
+ .initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bytesPerRow_bitsPerPixel_(
875
+ None, int(w * 2), int(h * 2), 8, 4, True, False,
876
+ "NSCalibratedRGBColorSpace", 0, 0))
877
+ rep.setSize_((w, h))
878
+ NSGraphicsContext.saveGraphicsState()
879
+ NSGraphicsContext.setCurrentContext_(
880
+ NSGraphicsContext.graphicsContextWithBitmapImageRep_(rep))
881
+ image.drawInRect_(NSMakeRect(0, 0, w, h))
882
+ NSGraphicsContext.restoreGraphicsState()
863
883
  png = rep.representationUsingType_properties_(4, {}) # 4 = PNG
864
884
  ok = png.writeToFile_atomically_(render_to, True)
865
885
  print(f"rendered popover -> {render_to}" if ok
@@ -20,7 +20,7 @@ import struct
20
20
  import numpy as np
21
21
  from nacl.signing import SigningKey, VerifyKey
22
22
 
23
- from . import crypto
23
+ from . import crypto, wire
24
24
  from .crypto import CryptoError, TamperError
25
25
  from .vaultfile import pack_sections, unpack_sections
26
26
 
@@ -71,7 +71,7 @@ def build_pack(*, name: str, version: str, description: str, records: list[dict]
71
71
  if passphrase is not None:
72
72
  salt = secrets.token_bytes(16)
73
73
  key = crypto.derive_key(passphrase.encode(), salt)
74
- body = crypto.seal(key, body, aad=b"engram-pack:" + name.encode())
74
+ body = crypto.seal(key, body, aad=wire.pack(name)[0])
75
75
  header["kdf"] = crypto.DEFAULT_KDF
76
76
  header["salt"] = salt.hex()
77
77
  header["content_sha256"] = crypto.sha256(body)
@@ -119,7 +119,7 @@ def read_pack(blob: bytes, passphrase: str | None = None
119
119
  raise PackError(f"Pack {header['name']!r} is encrypted; passphrase required")
120
120
  key = crypto.derive_key(passphrase.encode(), bytes.fromhex(header["salt"]),
121
121
  header["kdf"])
122
- body = crypto.unseal(key, body, aad=b"engram-pack:" + header["name"].encode())
122
+ body = crypto.unseal_any(key, body, *wire.pack(header["name"]))
123
123
 
124
124
  sections = unpack_sections(body)
125
125
  records = [json.loads(l) for l in
@@ -25,7 +25,7 @@ import os
25
25
  import stat
26
26
  from pathlib import Path
27
27
 
28
- from . import crypto
28
+ from . import crypto, wire
29
29
  from .crypto import CryptoError, TamperError
30
30
  from .platforms import boot_time, machine_id
31
31
 
@@ -49,17 +49,16 @@ def _uid() -> str:
49
49
  return str(getuid()) if getuid else os.environ.get("USERNAME", "user")
50
50
 
51
51
 
52
- def _boot_key() -> bytes:
52
+ def _boot_key(token: str = wire.SESSION_TOKEN) -> bytes:
53
53
  """Wrap key valid only for this boot session of this user on this
54
54
  machine. Uses the stable hardware machine id, NOT the hostname -
55
55
  macOS renames the host per network, which must not relock the vault."""
56
- token = "|".join((
57
- "engram-session-v2",
56
+ return hashlib.sha256("|".join((
57
+ token,
58
58
  _boot_time(),
59
59
  _uid(),
60
60
  machine_id(),
61
- ))
62
- return hashlib.sha256(token.encode()).digest()
61
+ )).encode()).digest()
63
62
 
64
63
 
65
64
  def _canon(vault_path: str) -> str:
@@ -83,7 +82,7 @@ def store(vault_path: str, master_key: bytes) -> Path:
83
82
  """Persist a boot-bound unlock credential for this vault."""
84
83
  p = _file_for(vault_path)
85
84
  blob = crypto.seal(_boot_key(), master_key,
86
- aad=b"engram-session:" + _canon(vault_path).encode())
85
+ aad=wire.session(_canon(vault_path))[0])
87
86
  p.write_text(json.dumps({"vault": _canon(vault_path),
88
87
  "wrapped": blob.hex()}), encoding="utf-8")
89
88
  os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) # 0600
@@ -92,15 +91,29 @@ def store(vault_path: str, master_key: bytes) -> Path:
92
91
 
93
92
  def get(vault_path: str) -> bytes | None:
94
93
  """Return the master key if a credential exists AND we are still in the
95
- same boot session; otherwise remove the stale file and return None."""
94
+ same boot session; otherwise remove the stale file and return None.
95
+
96
+ A credential written before 2.2 is bound to the older token and sealed
97
+ under the older associated data. It is accepted and then rewritten in the
98
+ current form, so upgrading Compartment does not relock a vault that was
99
+ already open."""
96
100
  p = _file_for(vault_path)
97
101
  if not p.is_file():
98
102
  return None
103
+ canon = _canon(vault_path)
104
+ aad, legacy_aad = wire.session(canon)
99
105
  try:
100
- data = json.loads(p.read_text(encoding="utf-8"))
101
- return crypto.unseal(
102
- _boot_key(), bytes.fromhex(data["wrapped"]),
103
- aad=b"engram-session:" + _canon(vault_path).encode())
106
+ blob = bytes.fromhex(json.loads(p.read_text(encoding="utf-8"))["wrapped"])
107
+ for token in (wire.SESSION_TOKEN, *wire.SESSION_TOKEN_LEGACY):
108
+ try:
109
+ key, used = crypto.unseal_which(_boot_key(token), blob,
110
+ aad, legacy_aad)
111
+ except TamperError:
112
+ continue
113
+ if token != wire.SESSION_TOKEN or used != aad:
114
+ store(vault_path, key)
115
+ return key
116
+ raise TamperError("No boot-session token opened the credential")
104
117
  except (TamperError, CryptoError, ValueError, KeyError, OSError):
105
118
  # different boot (restart/power loss) or corrupt file → locked
106
119
  try:
@@ -14,7 +14,7 @@ import uuid
14
14
 
15
15
  import numpy as np
16
16
 
17
- from . import crypto
17
+ from . import crypto, wire
18
18
  from .crypto import CryptoError
19
19
 
20
20
  SCHEMA = """
@@ -110,7 +110,7 @@ class Store:
110
110
  rid = record_id or uuid.uuid4().hex
111
111
  rk, wrapped = crypto.new_record_key(master_key, rid)
112
112
  ct = crypto.seal(rk, crypto.canonical_json({"text": text}),
113
- aad=b"engram-record-body:" + rid.encode())
113
+ aad=wire.record_body(rid)[0])
114
114
  now = time.time()
115
115
  self.conn.execute(
116
116
  "INSERT INTO records (id, ikey, ns, ct, key_wrapped, vec, dim, tags, importance,"
@@ -129,9 +129,35 @@ class Store:
129
129
 
130
130
  def decrypt_text(self, row: sqlite3.Row, master_key: bytes) -> str:
131
131
  rk = crypto.unwrap_record_key(master_key, row["id"], row["key_wrapped"])
132
- body = crypto.unseal(rk, row["ct"], aad=b"engram-record-body:" + row["id"].encode())
132
+ body = crypto.unseal_any(rk, row["ct"], *wire.record_body(row["id"]))
133
133
  return json.loads(body)["text"]
134
134
 
135
+ def migrate_wire(self, master_key: bytes) -> int:
136
+ """Rewrite every record under the current associated data.
137
+
138
+ Records are the one place the labels do not migrate for free: a row's
139
+ key wrap and ciphertext are written on insert and never touched again,
140
+ so without this pass every read of every pre-2.2 record would pay a
141
+ failed AEAD open before the one that works, forever. Vault.unlock
142
+ calls this once and records it in the header.
143
+
144
+ The record key is kept and re-wrapped rather than rotated, so the
145
+ plaintext is the only thing that round-trips. Nothing is written to
146
+ disk here: this mutates the in-memory database, and the caller's
147
+ save() is what makes it real, atomically. If any row fails to decrypt
148
+ the error propagates and no save happens, so a vault is never left
149
+ half-converted."""
150
+ rows = self.conn.execute("SELECT id, ct, key_wrapped FROM records").fetchall()
151
+ for row in rows:
152
+ rid = row["id"]
153
+ rk = crypto.unwrap_record_key(master_key, rid, row["key_wrapped"])
154
+ body = crypto.unseal_any(rk, row["ct"], *wire.record_body(rid))
155
+ self.conn.execute(
156
+ "UPDATE records SET ct = ?, key_wrapped = ? WHERE id = ?",
157
+ (crypto.seal(rk, body, aad=wire.record_body(rid)[0]),
158
+ crypto.wrap_record_key(master_key, rid, rk), rid))
159
+ return len(rows)
160
+
135
161
  def delete(self, record_id: str, shred: bool) -> bool:
136
162
  """Delete a record. shred=True also VACUUMs so the content (including
137
163
  FTS tokens and freed pages) is gone from the next serialized image,