compartment 2__tar.gz → 2.2__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 (64) hide show
  1. {compartment-2 → compartment-2.2}/PKG-INFO +24 -1
  2. {compartment-2 → compartment-2.2}/pyproject.toml +34 -1
  3. {compartment-2 → compartment-2.2}/src/compartment/__init__.py +1 -1
  4. {compartment-2 → compartment-2.2}/src/compartment/crypto.py +87 -14
  5. {compartment-2 → compartment-2.2}/src/compartment/menubar.py +111 -41
  6. {compartment-2 → compartment-2.2}/src/compartment/packs.py +3 -3
  7. {compartment-2 → compartment-2.2}/src/compartment/session.py +25 -12
  8. {compartment-2 → compartment-2.2}/src/compartment/store.py +29 -3
  9. {compartment-2 → compartment-2.2}/src/compartment/systray.py +105 -44
  10. {compartment-2 → compartment-2.2}/src/compartment/vault.py +74 -26
  11. {compartment-2 → compartment-2.2}/src/compartment/vaultfile.py +7 -5
  12. compartment-2.2/src/compartment/wire.py +130 -0
  13. {compartment-2 → compartment-2.2}/src/compartment.egg-info/PKG-INFO +24 -1
  14. {compartment-2 → compartment-2.2}/src/compartment.egg-info/SOURCES.txt +1 -0
  15. compartment-2.2/tests/test_ondisk_format.py +213 -0
  16. {compartment-2 → compartment-2.2}/tests/test_systray.py +30 -0
  17. compartment-2/tests/test_ondisk_format.py +0 -97
  18. {compartment-2 → compartment-2.2}/README.md +0 -0
  19. {compartment-2 → compartment-2.2}/setup.cfg +0 -0
  20. {compartment-2 → compartment-2.2}/src/compartment/acl.py +0 -0
  21. {compartment-2 → compartment-2.2}/src/compartment/audit.py +0 -0
  22. {compartment-2 → compartment-2.2}/src/compartment/bench.py +0 -0
  23. {compartment-2 → compartment-2.2}/src/compartment/claude_hooks.py +0 -0
  24. {compartment-2 → compartment-2.2}/src/compartment/claude_memory.py +0 -0
  25. {compartment-2 → compartment-2.2}/src/compartment/cli.py +0 -0
  26. {compartment-2 → compartment-2.2}/src/compartment/dash.py +0 -0
  27. {compartment-2 → compartment-2.2}/src/compartment/data/hermes-plugin/__init__.py +0 -0
  28. {compartment-2 → compartment-2.2}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
  29. {compartment-2 → compartment-2.2}/src/compartment/data/menubar.png +0 -0
  30. {compartment-2 → compartment-2.2}/src/compartment/data/menubar@2x.png +0 -0
  31. {compartment-2 → compartment-2.2}/src/compartment/data/starter.mpack +0 -0
  32. {compartment-2 → compartment-2.2}/src/compartment/data/tray.ico +0 -0
  33. {compartment-2 → compartment-2.2}/src/compartment/embed.py +0 -0
  34. {compartment-2 → compartment-2.2}/src/compartment/home.py +0 -0
  35. {compartment-2 → compartment-2.2}/src/compartment/longmemeval.py +0 -0
  36. {compartment-2 → compartment-2.2}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
  37. {compartment-2 → compartment-2.2}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
  38. {compartment-2 → compartment-2.2}/src/compartment/offline_guard.py +0 -0
  39. {compartment-2 → compartment-2.2}/src/compartment/platforms.py +0 -0
  40. {compartment-2 → compartment-2.2}/src/compartment/salience.py +0 -0
  41. {compartment-2 → compartment-2.2}/src/compartment/selftest.py +0 -0
  42. {compartment-2 → compartment-2.2}/src/compartment/server.py +0 -0
  43. {compartment-2 → compartment-2.2}/src/compartment/vindex.py +0 -0
  44. {compartment-2 → compartment-2.2}/src/compartment.egg-info/dependency_links.txt +0 -0
  45. {compartment-2 → compartment-2.2}/src/compartment.egg-info/entry_points.txt +0 -0
  46. {compartment-2 → compartment-2.2}/src/compartment.egg-info/requires.txt +0 -0
  47. {compartment-2 → compartment-2.2}/src/compartment.egg-info/top_level.txt +0 -0
  48. {compartment-2 → compartment-2.2}/tests/test_acl_audit_packs.py +0 -0
  49. {compartment-2 → compartment-2.2}/tests/test_claude_hooks_and_integrate.py +0 -0
  50. {compartment-2 → compartment-2.2}/tests/test_claude_memory_and_recent.py +0 -0
  51. {compartment-2 → compartment-2.2}/tests/test_crypto.py +0 -0
  52. {compartment-2 → compartment-2.2}/tests/test_dash.py +0 -0
  53. {compartment-2 → compartment-2.2}/tests/test_instructions.py +0 -0
  54. {compartment-2 → compartment-2.2}/tests/test_macos_bundle.py +0 -0
  55. {compartment-2 → compartment-2.2}/tests/test_menubar.py +0 -0
  56. {compartment-2 → compartment-2.2}/tests/test_offline_selftest_bench.py +0 -0
  57. {compartment-2 → compartment-2.2}/tests/test_platforms.py +0 -0
  58. {compartment-2 → compartment-2.2}/tests/test_relations.py +0 -0
  59. {compartment-2 → compartment-2.2}/tests/test_salience.py +0 -0
  60. {compartment-2 → compartment-2.2}/tests/test_session_lock.py +0 -0
  61. {compartment-2 → compartment-2.2}/tests/test_starter_packs.py +0 -0
  62. {compartment-2 → compartment-2.2}/tests/test_tamper.py +0 -0
  63. {compartment-2 → compartment-2.2}/tests/test_twofa.py +0 -0
  64. {compartment-2 → compartment-2.2}/tests/test_vault_ops.py +0 -0
@@ -1,7 +1,30 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 2
3
+ Version: 2.2
4
4
  Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
5
+ Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
6
+ Project-URL: Source, https://github.com/MaxFreedomPollard/Compartment
7
+ Project-URL: Issues, https://github.com/MaxFreedomPollard/Compartment/issues
8
+ Project-URL: Changelog, https://github.com/MaxFreedomPollard/Compartment/releases
9
+ Project-URL: Documentation, https://github.com/MaxFreedomPollard/Compartment#readme
10
+ Project-URL: Security, https://github.com/MaxFreedomPollard/Compartment/blob/main/SECURITY.md
11
+ Keywords: mcp,model-context-protocol,memory,agent-memory,vector-database,semantic-search,encryption,offline,local-first,claude,claude-code,privacy,rag
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Environment :: Console
15
+ Classifier: Environment :: MacOS X
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Operating System :: MacOS :: MacOS X
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Security :: Cryptography
25
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
26
+ Classifier: Topic :: Database :: Database Engines/Servers
27
+ Classifier: Typing :: Typed
5
28
  Requires-Python: >=3.11
6
29
  Description-Content-Type: text/markdown
7
30
  Requires-Dist: pynacl>=1.5
@@ -4,10 +4,31 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compartment"
7
- version = "2"
7
+ version = "2.2"
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"
11
+ keywords = ["mcp", "model-context-protocol", "memory", "agent-memory",
12
+ "vector-database", "semantic-search", "encryption", "offline",
13
+ "local-first", "claude", "claude-code", "privacy", "rag"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Environment :: Console",
18
+ "Environment :: MacOS X",
19
+ "Operating System :: OS Independent",
20
+ "Operating System :: MacOS :: MacOS X",
21
+ "Operating System :: Microsoft :: Windows",
22
+ "Operating System :: POSIX :: Linux",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Security :: Cryptography",
28
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
29
+ "Topic :: Database :: Database Engines/Servers",
30
+ "Typing :: Typed",
31
+ ]
11
32
  dependencies = [
12
33
  "pynacl>=1.5",
13
34
  "argon2-cffi>=23.1",
@@ -17,6 +38,18 @@ dependencies = [
17
38
  "mcp>=1.0",
18
39
  ]
19
40
 
41
+ # PyPI is the repo's declared homepage, so it is a real front door - and
42
+ # without these it is a dead end: no Source link, no Issues link, nothing
43
+ # carrying a reader back to GitHub. Directories that scrape PyPI were
44
+ # producing a listing with no repository link at all.
45
+ [project.urls]
46
+ Homepage = "https://github.com/MaxFreedomPollard/Compartment"
47
+ Source = "https://github.com/MaxFreedomPollard/Compartment"
48
+ Issues = "https://github.com/MaxFreedomPollard/Compartment/issues"
49
+ Changelog = "https://github.com/MaxFreedomPollard/Compartment/releases"
50
+ Documentation = "https://github.com/MaxFreedomPollard/Compartment#readme"
51
+ Security = "https://github.com/MaxFreedomPollard/Compartment/blob/main/SECURITY.md"
52
+
20
53
  [project.scripts]
21
54
  compartment = "compartment.cli:main"
22
55
  cmpt = "compartment.cli:main"
@@ -1,6 +1,6 @@
1
1
  """Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
2
2
 
3
- __version__ = "2"
3
+ __version__ = "2.2"
4
4
 
5
5
  from . import offline_guard as _og
6
6
 
@@ -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).
@@ -396,6 +396,8 @@ def run(vault: str | None = None, show: bool = False,
396
396
  NSRunningApplication,
397
397
  NSFont, NSImage, NSMakeRect, NSMenu, NSMenuItem,
398
398
  NSPanel, NSPopover, NSScreen, NSSegmentedControl,
399
+ NSClipView, NSScrollView,
400
+ NSBitmapImageRep, NSGraphicsContext,
399
401
  NSSecureTextField, NSStackView, NSStatusBar,
400
402
  NSSwitch, NSTextField,
401
403
  NSButton, NSView, NSViewController,
@@ -468,6 +470,17 @@ def run(vault: str | None = None, show: bool = False,
468
470
  CONTENT_WIDTH).setActive_(True)
469
471
  return b
470
472
 
473
+ class TopClip(NSClipView):
474
+ """A clip view whose origin is its top left corner.
475
+
476
+ AppKit's default is the bottom left, which inside a scroll view means
477
+ the panel opens showing its last line, with the title somewhere above
478
+ the fold. Flipping it makes "scrolled to the top" the resting state.
479
+ """
480
+
481
+ def isFlipped(self):
482
+ return True
483
+
471
484
  class Controller(NSObject):
472
485
  def init(self):
473
486
  self = objc.super(Controller, self).init()
@@ -485,8 +498,78 @@ def run(vault: str | None = None, show: bool = False,
485
498
  return self
486
499
 
487
500
  # ---- building the popover contents -----------------------------
501
+ @objc.python_method
502
+ def _finish(self, views):
503
+ stack = NSStackView.stackViewWithViews_(views)
504
+ stack.setOrientation_(_VERTICAL)
505
+ stack.setAlignment_(_LEADING)
506
+ stack.setSpacing_(6)
507
+ stack.setEdgeInsets_((CONTENT_INSET, CONTENT_INSET,
508
+ CONTENT_INSET, CONTENT_INSET))
509
+ stack.layoutSubtreeIfNeeded()
510
+ fit = stack.fittingSize()
511
+ stack.setFrameSize_((POPOVER_WIDTH, fit.height))
512
+ if fit.height <= POPOVER_MAX_HEIGHT:
513
+ return stack
514
+
515
+ # Taller than a popover is allowed to be. Scroll it. What this
516
+ # did before was cut the overflow off and say nothing, so the
517
+ # buttons below the fold were not merely out of reach - there
518
+ # was no sign they existed. That is how the passphrase form
519
+ # came to show one box out of two.
520
+ stack.setTranslatesAutoresizingMaskIntoConstraints_(True)
521
+ box = NSMakeRect(0, 0, POPOVER_WIDTH, POPOVER_MAX_HEIGHT)
522
+ scroll = NSScrollView.alloc().initWithFrame_(box)
523
+ scroll.setContentView_(TopClip.alloc().initWithFrame_(box))
524
+ scroll.setHasVerticalScroller_(True)
525
+ scroll.setDrawsBackground_(False)
526
+ scroll.setDocumentView_(stack)
527
+ return scroll
528
+
529
+ @objc.python_method
530
+ def buildChangeBody(self, st):
531
+ """Changing the passphrase gets the whole panel to itself.
532
+
533
+ The ordinary panel already fills POPOVER_MAX_HEIGHT, and this
534
+ popover clips instead of scrolling. Appending the fields to the
535
+ bottom of it put the second box, Save, and the error note below
536
+ the visible edge: you saw one box, pressed Return, and the save
537
+ failed its repeat check against an empty field you could neither
538
+ fill nor read the complaint about. A focused view cannot overflow.
539
+ """
540
+ def secure(placeholder):
541
+ f = NSSecureTextField.alloc().initWithFrame_(
542
+ NSMakeRect(0, 0, CONTENT_WIDTH, 24))
543
+ f.setPlaceholderString_(placeholder)
544
+ f.setTarget_(self)
545
+ f.setAction_("saveChangePw:") # Return saves
546
+ return f
547
+
548
+ self.pw_new = secure("New passphrase")
549
+ self.pw_repeat = secure("Repeat it")
550
+ views = [row(label("Change password", 15, bold=True), _spacer()),
551
+ label(summarise(st), 11, secondary=True),
552
+ divider(),
553
+ row(self.pw_new),
554
+ row(self.pw_repeat)]
555
+ if self.change_note:
556
+ views.append(label(self.change_note, 11, secondary=True,
557
+ wrap=True))
558
+ views.append(label("Both boxes must match. There is no recovery "
559
+ "phrase - if you forget this, the memories are "
560
+ "unrecoverable.", 10, secondary=True, wrap=True))
561
+ save_b = NSButton.buttonWithTitle_target_action_("Save", self,
562
+ "saveChangePw:")
563
+ save_b.setKeyEquivalent_("\r")
564
+ cancel_b = NSButton.buttonWithTitle_target_action_(
565
+ "Cancel", self, "cancelChangePw:")
566
+ views.append(row(save_b, cancel_b, _spacer()))
567
+ return self._finish(views)
568
+
488
569
  def buildBody(self):
489
570
  st = self.state
571
+ if self.changing_pw and st["exists"] and not st["locked"]:
572
+ return self.buildChangeBody(st)
490
573
  views = []
491
574
  title = row(label("Compartment", 15, bold=True),
492
575
  label("locked" if st["locked"] else "unlocked", 11,
@@ -516,33 +599,11 @@ def run(vault: str | None = None, show: bool = False,
516
599
  views.append(label("Stays unlocked until restart or Lock",
517
600
  10, secondary=True))
518
601
 
519
- # Changing the passphrase needs the vault open, because rekey
520
- # re-wraps a master key it can only hold while unlocked.
602
+ # Changing the passphrase is a separate view: see buildChangeBody.
521
603
  self.pw_new = self.pw_repeat = None
522
- if st["exists"] and not st["locked"] and self.changing_pw:
523
- def secure(placeholder):
524
- f = NSSecureTextField.alloc().initWithFrame_(
525
- NSMakeRect(0, 0, CONTENT_WIDTH - 92, 24))
526
- f.setPlaceholderString_(placeholder)
527
- f.setTarget_(self)
528
- f.setAction_("saveChangePw:") # Return saves
529
- return f
530
-
531
- self.pw_new = secure("New passphrase")
532
- self.pw_repeat = secure("Repeat it")
533
- save_b = NSButton.buttonWithTitle_target_action_(
534
- "Save", self, "saveChangePw:")
535
- cancel_b = NSButton.buttonWithTitle_target_action_(
536
- "Cancel", self, "cancelChangePw:")
537
- views.append(self.pw_new)
538
- views.append(row(self.pw_repeat, save_b))
539
- views.append(row(cancel_b, _spacer()))
540
- if self.change_note:
541
- views.append(label(self.change_note, 11, secondary=True,
542
- wrap=True))
543
- views.append(label("There is no recovery phrase. If you forget "
544
- "this, the memories are unrecoverable.",
545
- 10, secondary=True, wrap=True))
604
+ if self.change_note:
605
+ views.append(label(self.change_note, 11, secondary=True,
606
+ wrap=True))
546
607
  views.append(divider())
547
608
 
548
609
  views.append(label("SETTINGS", 10, bold=True, secondary=True))
@@ -613,17 +674,7 @@ def run(vault: str | None = None, show: bool = False,
613
674
  else:
614
675
  views.append(row(refresh, _spacer(), quit_b))
615
676
 
616
- stack = NSStackView.stackViewWithViews_(views)
617
- stack.setOrientation_(_VERTICAL)
618
- stack.setAlignment_(_LEADING)
619
- stack.setSpacing_(6)
620
- stack.setEdgeInsets_((CONTENT_INSET, CONTENT_INSET,
621
- CONTENT_INSET, CONTENT_INSET))
622
- stack.layoutSubtreeIfNeeded()
623
- fit = stack.fittingSize()
624
- stack.setFrameSize_((POPOVER_WIDTH,
625
- min(fit.height, POPOVER_MAX_HEIGHT)))
626
- return stack
677
+ return self._finish(views)
627
678
 
628
679
  # Not an action: PyObjC infers a selector from the method name, and
629
680
  # a name with no trailing underscores means "takes no arguments".
@@ -802,14 +853,33 @@ def run(vault: str | None = None, show: bool = False,
802
853
  _d("controller ready")
803
854
 
804
855
  if render_to:
805
- # app.run() normally does this; without it the text system is not up
806
- # 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.
807
857
  app.finishLaunching()
808
858
  body = ctrl.buildBody()
809
859
  body.layoutSubtreeIfNeeded()
810
860
  bounds = body.bounds()
811
- rep = body.bitmapImageRepForCachingDisplayInRect_(bounds)
812
- 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()
813
883
  png = rep.representationUsingType_properties_(4, {}) # 4 = PNG
814
884
  ok = png.writeToFile_atomically_(render_to, True)
815
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,