compartment 2.1__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.1 → compartment-2.2}/PKG-INFO +1 -1
  2. {compartment-2.1 → compartment-2.2}/pyproject.toml +1 -1
  3. {compartment-2.1 → compartment-2.2}/src/compartment/__init__.py +1 -1
  4. {compartment-2.1 → compartment-2.2}/src/compartment/crypto.py +87 -14
  5. {compartment-2.1 → compartment-2.2}/src/compartment/menubar.py +24 -4
  6. {compartment-2.1 → compartment-2.2}/src/compartment/packs.py +3 -3
  7. {compartment-2.1 → compartment-2.2}/src/compartment/session.py +25 -12
  8. {compartment-2.1 → compartment-2.2}/src/compartment/store.py +29 -3
  9. {compartment-2.1 → compartment-2.2}/src/compartment/vault.py +74 -26
  10. {compartment-2.1 → compartment-2.2}/src/compartment/vaultfile.py +7 -5
  11. compartment-2.2/src/compartment/wire.py +130 -0
  12. {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/PKG-INFO +1 -1
  13. {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/SOURCES.txt +1 -0
  14. compartment-2.2/tests/test_ondisk_format.py +213 -0
  15. compartment-2.1/tests/test_ondisk_format.py +0 -97
  16. {compartment-2.1 → compartment-2.2}/README.md +0 -0
  17. {compartment-2.1 → compartment-2.2}/setup.cfg +0 -0
  18. {compartment-2.1 → compartment-2.2}/src/compartment/acl.py +0 -0
  19. {compartment-2.1 → compartment-2.2}/src/compartment/audit.py +0 -0
  20. {compartment-2.1 → compartment-2.2}/src/compartment/bench.py +0 -0
  21. {compartment-2.1 → compartment-2.2}/src/compartment/claude_hooks.py +0 -0
  22. {compartment-2.1 → compartment-2.2}/src/compartment/claude_memory.py +0 -0
  23. {compartment-2.1 → compartment-2.2}/src/compartment/cli.py +0 -0
  24. {compartment-2.1 → compartment-2.2}/src/compartment/dash.py +0 -0
  25. {compartment-2.1 → compartment-2.2}/src/compartment/data/hermes-plugin/__init__.py +0 -0
  26. {compartment-2.1 → compartment-2.2}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
  27. {compartment-2.1 → compartment-2.2}/src/compartment/data/menubar.png +0 -0
  28. {compartment-2.1 → compartment-2.2}/src/compartment/data/menubar@2x.png +0 -0
  29. {compartment-2.1 → compartment-2.2}/src/compartment/data/starter.mpack +0 -0
  30. {compartment-2.1 → compartment-2.2}/src/compartment/data/tray.ico +0 -0
  31. {compartment-2.1 → compartment-2.2}/src/compartment/embed.py +0 -0
  32. {compartment-2.1 → compartment-2.2}/src/compartment/home.py +0 -0
  33. {compartment-2.1 → compartment-2.2}/src/compartment/longmemeval.py +0 -0
  34. {compartment-2.1 → compartment-2.2}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
  35. {compartment-2.1 → compartment-2.2}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
  36. {compartment-2.1 → compartment-2.2}/src/compartment/offline_guard.py +0 -0
  37. {compartment-2.1 → compartment-2.2}/src/compartment/platforms.py +0 -0
  38. {compartment-2.1 → compartment-2.2}/src/compartment/salience.py +0 -0
  39. {compartment-2.1 → compartment-2.2}/src/compartment/selftest.py +0 -0
  40. {compartment-2.1 → compartment-2.2}/src/compartment/server.py +0 -0
  41. {compartment-2.1 → compartment-2.2}/src/compartment/systray.py +0 -0
  42. {compartment-2.1 → compartment-2.2}/src/compartment/vindex.py +0 -0
  43. {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/dependency_links.txt +0 -0
  44. {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/entry_points.txt +0 -0
  45. {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/requires.txt +0 -0
  46. {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/top_level.txt +0 -0
  47. {compartment-2.1 → compartment-2.2}/tests/test_acl_audit_packs.py +0 -0
  48. {compartment-2.1 → compartment-2.2}/tests/test_claude_hooks_and_integrate.py +0 -0
  49. {compartment-2.1 → compartment-2.2}/tests/test_claude_memory_and_recent.py +0 -0
  50. {compartment-2.1 → compartment-2.2}/tests/test_crypto.py +0 -0
  51. {compartment-2.1 → compartment-2.2}/tests/test_dash.py +0 -0
  52. {compartment-2.1 → compartment-2.2}/tests/test_instructions.py +0 -0
  53. {compartment-2.1 → compartment-2.2}/tests/test_macos_bundle.py +0 -0
  54. {compartment-2.1 → compartment-2.2}/tests/test_menubar.py +0 -0
  55. {compartment-2.1 → compartment-2.2}/tests/test_offline_selftest_bench.py +0 -0
  56. {compartment-2.1 → compartment-2.2}/tests/test_platforms.py +0 -0
  57. {compartment-2.1 → compartment-2.2}/tests/test_relations.py +0 -0
  58. {compartment-2.1 → compartment-2.2}/tests/test_salience.py +0 -0
  59. {compartment-2.1 → compartment-2.2}/tests/test_session_lock.py +0 -0
  60. {compartment-2.1 → compartment-2.2}/tests/test_starter_packs.py +0 -0
  61. {compartment-2.1 → compartment-2.2}/tests/test_systray.py +0 -0
  62. {compartment-2.1 → compartment-2.2}/tests/test_tamper.py +0 -0
  63. {compartment-2.1 → compartment-2.2}/tests/test_twofa.py +0 -0
  64. {compartment-2.1 → compartment-2.2}/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.2
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.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"
@@ -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.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).
@@ -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,
@@ -25,7 +25,7 @@ import uuid
25
25
 
26
26
  import numpy as np
27
27
 
28
- from . import audit, crypto, vaultfile
28
+ from . import audit, crypto, vaultfile, wire
29
29
  from .platforms import FileLock
30
30
  from .acl import AclError, VaultConfig
31
31
  from .crypto import CryptoError, TamperError
@@ -140,7 +140,7 @@ class Vault:
140
140
  keyslots=[slot_pw],
141
141
  payload_len=0,
142
142
  model={"name": model_name, "sha256": emb.model_sha256, "dim": emb.dim},
143
- extra={"creator": creator},
143
+ extra={"creator": creator, "wire": wire.WIRE_FORMAT},
144
144
  )
145
145
  db = Store()
146
146
  db.set_meta("model_name", model_name)
@@ -164,9 +164,14 @@ class Vault:
164
164
  if raw_key is not None:
165
165
  master = raw_key
166
166
  # verify the key actually opens this vault (AEAD auth below)
167
+ slots_migrated = False
167
168
  elif passphrase is not None:
169
+ # open_slot rewrites a pre-2.2 keyslot in place as it opens it, so
170
+ # compare rather than trust: whatever changed has to reach disk.
171
+ before = crypto.canonical_json(loaded.header.keyslots)
168
172
  master = crypto.unwrap_master(loaded.header.keyslots, passphrase,
169
173
  keyfile=keyfile)
174
+ slots_migrated = crypto.canonical_json(loaded.header.keyslots) != before
170
175
  else:
171
176
  raise CryptoError("No credential provided (passphrase or key)")
172
177
  sections = vaultfile.decrypt_payload(loaded.header, loaded.payload_ct, master)
@@ -180,7 +185,8 @@ class Vault:
180
185
  for e in entries:
181
186
  v._replay(e)
182
187
  merged = v._merge_legacy_starter()
183
- if entries or loaded.truncated_tail or merged:
188
+ rewired = v._migrate_wire_format()
189
+ if entries or loaded.truncated_tail or merged or rewired or slots_migrated:
184
190
  if loaded.truncated_tail:
185
191
  print("notice: discarded one unacknowledged (crash-truncated) write")
186
192
  v.save() # compact replayed journal into the payload
@@ -199,6 +205,24 @@ class Vault:
199
205
  v._rebuild_index()
200
206
  return v
201
207
 
208
+ def _migrate_wire_format(self) -> int:
209
+ """Bring a vault written by an older build onto the current labels.
210
+
211
+ Compartment was engRAM until 1.15.0 and the name was still in the
212
+ bytes until 2.2: associated data, KDF context, keychain identity. See
213
+ wire.py for what those are and why they are not branding.
214
+
215
+ Almost all of it moves for free, because the payload and journal are
216
+ resealed on every save anyway. Records are the exception, so they get
217
+ an explicit pass here. It runs once, gated on a header marker, and
218
+ writes nothing itself: the caller's save() commits the whole thing
219
+ atomically, or an exception means none of it lands."""
220
+ if self.header.extra.get("wire", 1) >= wire.WIRE_FORMAT:
221
+ return 0
222
+ n = self.db.migrate_wire(self._master)
223
+ self.header.extra["wire"] = wire.WIRE_FORMAT
224
+ return n or 1 # truthy even for an empty vault: the marker must save
225
+
202
226
  def _merge_legacy_starter(self) -> int:
203
227
  """One-time reorganization: fold the legacy read-only packs/starter
204
228
  section into "main", so starting memories are ordinary memories -
@@ -822,21 +846,33 @@ class Vault:
822
846
  # macOS Keychain integration (optional keyslot type "keychain")
823
847
  # ---------------------------------------------------------------------------
824
848
 
825
- def _keychain_service(path: str) -> str:
826
- """Keychain identity for a vault.
849
+ def _keychain_names(path: str) -> list[tuple[str, str]]:
850
+ """(account, service) pairs identifying this vault's Keychain item,
851
+ current spelling first.
827
852
 
828
- Frozen at the pre-rename spelling, like the on-disk constants: this names
829
- a credential macOS already stored. Renaming it does not migrate anything,
830
- it just stops finding the item, and the vault reports itself locked with
831
- no indication why."""
832
- return f"engram-vault:{os.path.abspath(path)}"
853
+ These name a credential macOS has already stored, so they behave like the
854
+ on-disk labels: renaming does not move the item, it just stops finding it,
855
+ and the vault would report itself locked with no indication why. The
856
+ legacy pair is therefore still looked up, and keychain_get moves the item
857
+ across the first time it finds one."""
858
+ abspath = os.path.abspath(path)
859
+ pairs = [(wire.KEYCHAIN_ACCOUNT, wire.KEYCHAIN_SERVICE + abspath)]
860
+ pairs += [(acct, svc + abspath)
861
+ for acct in wire.KEYCHAIN_ACCOUNT_LEGACY
862
+ for svc in wire.KEYCHAIN_SERVICE_LEGACY]
863
+ return pairs
864
+
865
+
866
+ def _keychain_service(path: str) -> str:
867
+ return wire.KEYCHAIN_SERVICE + os.path.abspath(path)
833
868
 
834
869
 
835
870
  def keychain_store(path: str, master_key: bytes) -> None:
836
871
  if platform.system() != "Darwin":
837
872
  raise CryptoError("Keychain unlock is only available on macOS")
838
873
  subprocess.run(
839
- ["security", "add-generic-password", "-U", "-a", "engram",
874
+ ["security", "add-generic-password", "-U",
875
+ "-a", wire.KEYCHAIN_ACCOUNT,
840
876
  "-s", _keychain_service(path), "-w", master_key.hex()],
841
877
  check=True, capture_output=True)
842
878
 
@@ -844,23 +880,35 @@ def keychain_store(path: str, master_key: bytes) -> None:
844
880
  def keychain_get(path: str) -> bytes | None:
845
881
  if platform.system() != "Darwin":
846
882
  return None
847
- r = subprocess.run(
848
- ["security", "find-generic-password", "-a", "engram",
849
- "-s", _keychain_service(path), "-w"],
850
- capture_output=True, text=True)
851
- if r.returncode != 0:
852
- return None
853
- try:
854
- return bytes.fromhex(r.stdout.strip())
855
- except ValueError:
856
- return None
883
+ for i, (account, service) in enumerate(_keychain_names(path)):
884
+ r = subprocess.run(
885
+ ["security", "find-generic-password", "-a", account,
886
+ "-s", service, "-w"],
887
+ capture_output=True, text=True)
888
+ if r.returncode != 0:
889
+ continue
890
+ try:
891
+ key = bytes.fromhex(r.stdout.strip())
892
+ except ValueError:
893
+ return None
894
+ if i:
895
+ # found under the pre-rename name: re-file it, then drop the old
896
+ # item so this lookup costs one call again from here on.
897
+ keychain_store(path, key)
898
+ subprocess.run(
899
+ ["security", "delete-generic-password", "-a", account,
900
+ "-s", service], capture_output=True)
901
+ return key
902
+ return None
857
903
 
858
904
 
859
905
  def keychain_clear(path: str) -> bool:
860
906
  if platform.system() != "Darwin":
861
907
  return False
862
- r = subprocess.run(
863
- ["security", "delete-generic-password", "-a", "engram",
864
- "-s", _keychain_service(path)],
865
- capture_output=True)
866
- return r.returncode == 0
908
+ cleared = False
909
+ for account, service in _keychain_names(path):
910
+ r = subprocess.run(
911
+ ["security", "delete-generic-password", "-a", account,
912
+ "-s", service], capture_output=True)
913
+ cleared = cleared or r.returncode == 0
914
+ return cleared
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
23
23
 
24
24
  from nacl.signing import SigningKey, VerifyKey
25
25
 
26
- from . import crypto
26
+ from . import crypto, wire
27
27
  from .crypto import CryptoError, TamperError
28
28
 
29
29
  MAGIC = b"NUCV"
@@ -94,11 +94,11 @@ def _atomic_replace(tmp: str, path: str) -> None:
94
94
 
95
95
 
96
96
  def _payload_aad(vault_id: str) -> bytes:
97
- return b"engram-payload:" + vault_id.encode()
97
+ return wire.payload(vault_id)[0]
98
98
 
99
99
 
100
100
  def _journal_aad(vault_id: str, seq: int) -> bytes:
101
- return b"engram-journal:" + vault_id.encode() + b":" + struct.pack(">Q", seq)
101
+ return wire.journal(vault_id, seq)[0]
102
102
 
103
103
 
104
104
  # ---------------------------------------------------------------------------
@@ -188,14 +188,16 @@ def read_vault_file(path: str) -> LoadedVaultFile:
188
188
 
189
189
 
190
190
  def decrypt_payload(header: VaultHeader, payload_ct: bytes, master_key: bytes) -> dict[str, bytes]:
191
- plain = crypto.unseal(master_key, payload_ct, aad=_payload_aad(header.vault_id))
191
+ plain = crypto.unseal_any(master_key, payload_ct, *wire.payload(header.vault_id))
192
192
  return unpack_sections(plain)
193
193
 
194
194
 
195
195
  def decrypt_journal(header: VaultHeader, journal_cts: list[bytes], master_key: bytes) -> list[dict]:
196
+ """Entries are decrypted one at a time and each falls back on its own, so
197
+ a vault upgraded mid-journal (older entries, newer appends) still replays."""
196
198
  entries = []
197
199
  for seq, ct in enumerate(journal_cts):
198
- plain = crypto.unseal(master_key, ct, aad=_journal_aad(header.vault_id, seq))
200
+ plain = crypto.unseal_any(master_key, ct, *wire.journal(header.vault_id, seq))
199
201
  entries.append(json.loads(plain))
200
202
  return entries
201
203
 
@@ -0,0 +1,130 @@
1
+ """The byte strings Compartment writes into vaults, packs and session files.
2
+
3
+ None of this is naming. Every constant below is AEAD associated data, a KDF
4
+ context label or a credential identifier, which means it is baked into bytes
5
+ that are already on disk. Changing one does not rename anything: it makes data
6
+ written under the old spelling undecryptable.
7
+
8
+ So each constant comes as a pair. The plain name is what gets written. The
9
+ `_LEGACY` tuple beside it is every spelling that has ever been written and
10
+ must still open. Compartment was called engRAM until 1.15.0, so anything
11
+ created before then carries `engram` in its bytes.
12
+
13
+ Reading tries the current spelling first and falls back through the legacy
14
+ ones (`crypto.unseal_any`). Writing only ever uses the current spelling. A
15
+ vault made by an older build therefore opens normally, and `Vault.unlock`
16
+ rewrites it in the current format once, on the spot. There is nothing for the
17
+ user to run and nothing to convert.
18
+
19
+ If you are modifying this file: to add a label, give it a value and an empty
20
+ legacy tuple. To change one, move the old value into its legacy tuple and
21
+ never delete it. A legacy entry removed is a vault nobody can open again.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import struct
26
+
27
+ # Bumped when any label below changes. `Vault.unlock` compares it against
28
+ # `header.extra["wire"]` to decide whether a vault needs rewriting, so this is
29
+ # the one number that says "these bytes are current".
30
+ # 1 = engRAM spelling, everything up to 2.1
31
+ # 2 = Compartment spelling
32
+ WIRE_FORMAT = 2
33
+
34
+ # --- keyslots --------------------------------------------------------------
35
+
36
+ # Associated data wrapping the vault master key. The most load-bearing string
37
+ # in the project: if a keyslot will not unseal, the vault is gone.
38
+ KEYSLOT = b"compartment-keyslot"
39
+ KEYSLOT_LEGACY = (b"engram-keyslot",)
40
+
41
+ # Domain separator between the knowledge factor (passphrase) and the
42
+ # possession factor (keyfile bytes). This one is a KDF *input*, not associated
43
+ # data, so an old separator produces a different wrap key and costs a second
44
+ # Argon2id pass to migrate. See crypto.open_slot.
45
+ KEYFILE_SEP = b"\x1f compartment-2fa \x1f"
46
+ KEYFILE_SEP_LEGACY = (b"\x1f engram-2fa \x1f",)
47
+
48
+ # --- vault file ------------------------------------------------------------
49
+
50
+ _PAYLOAD = b"compartment-payload:"
51
+ _PAYLOAD_LEGACY = (b"engram-payload:",)
52
+
53
+ _JOURNAL = b"compartment-journal:"
54
+ _JOURNAL_LEGACY = (b"engram-journal:",)
55
+
56
+ # --- records ---------------------------------------------------------------
57
+
58
+ _RECORD_KEY = b"compartment-record:"
59
+ _RECORD_KEY_LEGACY = (b"engram-record:",)
60
+
61
+ _RECORD_BODY = b"compartment-record-body:"
62
+ _RECORD_BODY_LEGACY = (b"engram-record-body:",)
63
+
64
+ # --- memory packs ----------------------------------------------------------
65
+
66
+ _PACK = b"compartment-pack:"
67
+ _PACK_LEGACY = (b"engram-pack:",)
68
+
69
+ # --- boot session credential ----------------------------------------------
70
+
71
+ _SESSION = b"compartment-session:"
72
+ _SESSION_LEGACY = (b"engram-session:",)
73
+
74
+ # Hashed into the boot-bound wrap key. Not associated data, but the same
75
+ # hazard: change it and every live session file stops opening, which locks
76
+ # every unlocked vault on the machine until the user types a passphrase again.
77
+ SESSION_TOKEN = "compartment-session-v3"
78
+ SESSION_TOKEN_LEGACY = ("engram-session-v2",)
79
+
80
+ # --- macOS Keychain --------------------------------------------------------
81
+
82
+ # Names an item macOS has already stored. A rename here does not move the
83
+ # item, it just stops finding it, so keychain_get migrates on the way past.
84
+ KEYCHAIN_SERVICE = "compartment-vault:"
85
+ KEYCHAIN_SERVICE_LEGACY = ("engram-vault:",)
86
+
87
+ KEYCHAIN_ACCOUNT = "compartment"
88
+ KEYCHAIN_ACCOUNT_LEGACY = ("engram",)
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Labels that carry an identifier. Each returns (current, legacy_forms) ready
93
+ # to splat into crypto.unseal_any.
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def _with(current: bytes, legacy: tuple[bytes, ...], tail: bytes
97
+ ) -> tuple[bytes, tuple[bytes, ...]]:
98
+ return current + tail, tuple(old + tail for old in legacy)
99
+
100
+
101
+ def payload(vault_id: str) -> tuple[bytes, tuple[bytes, ...]]:
102
+ """Associated data over the whole sealed payload."""
103
+ return _with(_PAYLOAD, _PAYLOAD_LEGACY, vault_id.encode())
104
+
105
+
106
+ def journal(vault_id: str, seq: int) -> tuple[bytes, tuple[bytes, ...]]:
107
+ """Associated data over one journal entry. The sequence number is bound in
108
+ so entries cannot be reordered or replayed."""
109
+ return _with(_JOURNAL, _JOURNAL_LEGACY,
110
+ vault_id.encode() + b":" + struct.pack(">Q", seq))
111
+
112
+
113
+ def record_key(record_id: str) -> tuple[bytes, tuple[bytes, ...]]:
114
+ """Associated data wrapping one record's data key (the crypto-shred unit)."""
115
+ return _with(_RECORD_KEY, _RECORD_KEY_LEGACY, record_id.encode())
116
+
117
+
118
+ def record_body(record_id: str) -> tuple[bytes, tuple[bytes, ...]]:
119
+ """Associated data over one record's ciphertext."""
120
+ return _with(_RECORD_BODY, _RECORD_BODY_LEGACY, record_id.encode())
121
+
122
+
123
+ def pack(name: str) -> tuple[bytes, tuple[bytes, ...]]:
124
+ """Associated data over an encrypted memory pack body."""
125
+ return _with(_PACK, _PACK_LEGACY, name.encode())
126
+
127
+
128
+ def session(vault_path: str) -> tuple[bytes, tuple[bytes, ...]]:
129
+ """Associated data over a boot-session unlock credential."""
130
+ return _with(_SESSION, _SESSION_LEGACY, vault_path.encode())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 2.1
3
+ Version: 2.2
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
@@ -25,6 +25,7 @@ src/compartment/systray.py
25
25
  src/compartment/vault.py
26
26
  src/compartment/vaultfile.py
27
27
  src/compartment/vindex.py
28
+ src/compartment/wire.py
28
29
  src/compartment.egg-info/PKG-INFO
29
30
  src/compartment.egg-info/SOURCES.txt
30
31
  src/compartment.egg-info/dependency_links.txt
@@ -0,0 +1,213 @@
1
+ """The on-disk format is not branding, and must survive every rename.
2
+
3
+ Compartment was called engRAM until 1.15.0, and the name was still in the
4
+ bytes until 2.2: AEAD associated data, KDF context labels, credential
5
+ identifiers. Changing one of those does not rename anything. It makes data
6
+ already written under the old spelling undecryptable.
7
+
8
+ `engram-keyslot` is the worst of them: it is the associated data wrapping the
9
+ master key. Lose it and no keyslot unseals, which means the vault cannot be
10
+ opened again by anyone, with any passphrase, ever. `engram-record:`,
11
+ `engram-record-body:` and `engram-payload:` are the same hazard one layer down.
12
+
13
+ This is not hypothetical. The 1.15.0 rename did a find-and-replace across the
14
+ source and rewrote all sixteen of them. It surfaced only because the vault
15
+ started reporting itself locked; had the session not broken first, the next
16
+ person to run it against a real vault would have found their data unreadable
17
+ and no error explaining why.
18
+
19
+ 2.2 renamed them deliberately, which is a different thing: every old spelling
20
+ moved into a legacy tuple in wire.py and is still tried on read. So the rule
21
+ is no longer "these constants are frozen". It is:
22
+
23
+ every spelling ever written stays readable, forever.
24
+
25
+ These tests hold that line from both ends. The literal checks catch a legacy
26
+ value being deleted or a find-and-replace sweeping through wire.py. The
27
+ round-trip tests are the ones that matter: they build genuine pre-2.2 bytes
28
+ and prove the current code still opens them.
29
+ """
30
+ import pathlib
31
+
32
+ import pytest
33
+
34
+ from compartment import crypto, store, vaultfile, wire
35
+
36
+ SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "compartment"
37
+
38
+ # Every legacy spelling, and the wire.py attribute it has to stay in. Deleting
39
+ # any one of these entries is the permanent-data-loss move.
40
+ LEGACY = [
41
+ ("KEYSLOT_LEGACY", b"engram-keyslot"),
42
+ ("KEYFILE_SEP_LEGACY", b"\x1f engram-2fa \x1f"),
43
+ ("_PAYLOAD_LEGACY", b"engram-payload:"),
44
+ ("_JOURNAL_LEGACY", b"engram-journal:"),
45
+ ("_RECORD_KEY_LEGACY", b"engram-record:"),
46
+ ("_RECORD_BODY_LEGACY", b"engram-record-body:"),
47
+ ("_PACK_LEGACY", b"engram-pack:"),
48
+ ("_SESSION_LEGACY", b"engram-session:"),
49
+ ("SESSION_TOKEN_LEGACY", "engram-session-v2"),
50
+ ("KEYCHAIN_SERVICE_LEGACY", "engram-vault:"),
51
+ ("KEYCHAIN_ACCOUNT_LEGACY", "engram"),
52
+ ]
53
+
54
+
55
+ @pytest.mark.parametrize("attr,value", LEGACY)
56
+ def test_every_legacy_spelling_is_still_declared(attr, value):
57
+ assert value in getattr(wire, attr), (
58
+ f"wire.{attr} no longer lists {value!r}. That is not a name, it is "
59
+ f"associated data in bytes that are already on disk. Removing it "
60
+ f"makes every vault still carrying it undecryptable, permanently."
61
+ )
62
+
63
+
64
+ def test_wire_is_the_only_place_the_labels_live():
65
+ """One file to check before the next rename.
66
+
67
+ The 1.15.0 accident happened because these constants were scattered across
68
+ six modules, so a find-and-replace hit them all and no single file looked
69
+ wrong afterwards."""
70
+ strays = []
71
+ for path in sorted(SRC.glob("*.py")):
72
+ if path.name in ("wire.py", "home.py"):
73
+ continue # wire.py owns them; home.py handles ~/.engram
74
+ for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
75
+ if "engram" in line:
76
+ strays.append(f"{path.name}:{n}: {line.strip()}")
77
+ assert not strays, (
78
+ "on-disk labels must be declared in wire.py, not inline:\n "
79
+ + "\n ".join(strays))
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # The real test: build pre-2.2 bytes, open them with current code.
84
+ # ---------------------------------------------------------------------------
85
+
86
+ @pytest.fixture()
87
+ def legacy_wire(monkeypatch):
88
+ """Make the writing side emit exactly what a pre-2.2 build emitted."""
89
+ for attr, value in LEGACY:
90
+ monkeypatch.setattr(wire, attr.removesuffix("_LEGACY"), value)
91
+ monkeypatch.setattr(wire, "WIRE_FORMAT", 1)
92
+ return wire
93
+
94
+
95
+ def test_a_legacy_keyslot_still_opens_and_migrates_itself(legacy_wire, monkeypatch):
96
+ master = crypto.new_key()
97
+ slot = crypto.make_passphrase_slot(master, "correct horse")
98
+ assert b"engram-keyslot" == wire.KEYSLOT # the fixture is honest
99
+ monkeypatch.undo() # back to current code
100
+
101
+ assert crypto.open_slot(slot, "correct horse") == master
102
+ # and it rewrote itself on the way through, so the next open is direct
103
+ assert crypto.unseal(
104
+ crypto.derive_key(b"correct horse", bytes.fromhex(slot["salt"]),
105
+ slot["kdf"]),
106
+ bytes.fromhex(slot["wrapped"]), aad=wire.KEYSLOT) == master
107
+
108
+
109
+ def test_a_legacy_record_still_reads_and_migrates(legacy_wire, monkeypatch):
110
+ master = crypto.new_key()
111
+ db = store.Store()
112
+ rid = db.insert(record_id=None, ns="main", text="a legacy memory",
113
+ vec=_vec(), tags=[], importance=0.5, quarantined=False,
114
+ pack=None, prov={}, master_key=master)
115
+ monkeypatch.undo()
116
+
117
+ assert db.decrypt_text(db.get_row(rid), master) == "a legacy memory"
118
+ assert db.migrate_wire(master) == 1
119
+ row = db.get_row(rid)
120
+ assert db.decrypt_text(row, master) == "a legacy memory"
121
+ # now genuinely current: the legacy label is no longer what opens it
122
+ rk = crypto.unseal(master, row["key_wrapped"], aad=wire.record_key(rid)[0])
123
+ assert crypto.unseal(rk, row["ct"], aad=wire.record_body(rid)[0])
124
+
125
+
126
+ def test_a_legacy_vault_opens_migrates_and_keeps_every_memory(
127
+ legacy_wire, monkeypatch, vault_path):
128
+ """End to end, through the real Vault, on a real file."""
129
+ from compartment.vault import Vault
130
+
131
+ v = Vault.create(vault_path, "CorrectHorse", creator="test")
132
+ for text in ("the first memory", "the second memory", "the third"):
133
+ v.store(text, caller="test")
134
+ v.save()
135
+ v.lock()
136
+ assert v.header.extra["wire"] == 1
137
+ monkeypatch.undo()
138
+
139
+ reopened = Vault.unlock(vault_path, passphrase="CorrectHorse")
140
+ assert reopened.header.extra["wire"] == wire.WIRE_FORMAT
141
+ texts = {reopened.db.decrypt_text(r, reopened._master)
142
+ for r in reopened.db.conn.execute("SELECT * FROM records")}
143
+ assert {"the first memory", "the second memory", "the third"} <= texts
144
+
145
+ # The migration persisted, and it is genuinely current rather than just
146
+ # marked so: every layer now authenticates under the current label with no
147
+ # fallback. Checking the file for "engram" would prove nothing - associated
148
+ # data is an input to the AEAD, never stored, so a legacy vault does not
149
+ # contain the string either.
150
+ reopened.lock()
151
+ again = Vault.unlock(vault_path, passphrase="CorrectHorse")
152
+ assert again.header.extra["wire"] == wire.WIRE_FORMAT
153
+
154
+ loaded = vaultfile.read_vault_file(vault_path)
155
+ key = again._master
156
+ crypto.unseal(key, loaded.payload_ct,
157
+ aad=wire.payload(loaded.header.vault_id)[0])
158
+ for row in again.db.conn.execute("SELECT * FROM records"):
159
+ rk = crypto.unseal(key, row["key_wrapped"],
160
+ aad=wire.record_key(row["id"])[0])
161
+ crypto.unseal(rk, row["ct"], aad=wire.record_body(row["id"])[0])
162
+ slot = again.header.keyslots[0]
163
+ crypto.unseal(
164
+ crypto.derive_key(b"CorrectHorse", bytes.fromhex(slot["salt"]),
165
+ slot["kdf"]),
166
+ bytes.fromhex(slot["wrapped"]), aad=wire.KEYSLOT)
167
+
168
+
169
+ def test_a_legacy_session_credential_is_accepted_and_rewritten(
170
+ legacy_wire, monkeypatch, tmp_path):
171
+ from compartment import session
172
+
173
+ monkeypatch.setenv("COMPARTMENT_SESSION_DIR", str(tmp_path / "session"))
174
+ master = crypto.new_key()
175
+ vault = str(tmp_path / "x.vault")
176
+ session.store(vault, master)
177
+ monkeypatch.undo()
178
+ monkeypatch.setenv("COMPARTMENT_SESSION_DIR", str(tmp_path / "session"))
179
+
180
+ assert session.get(vault) == master # opened despite the old token
181
+ assert session.get(vault) == master # and again, now rewritten
182
+
183
+
184
+ def test_a_legacy_encrypted_pack_still_opens(legacy_wire, monkeypatch, tmp_path):
185
+ from compartment import packs
186
+ import numpy as np
187
+
188
+ identity = packs.new_identity("author")
189
+ blob = packs.build_pack(
190
+ name="demo", version="1", description="d",
191
+ records=[{"text": "packed memory", "namespace": "main", "tags": [],
192
+ "importance": 0.5}],
193
+ vectors=np.zeros((1, 8), dtype=np.float32),
194
+ model={"name": "m", "sha256": "x", "dim": 8},
195
+ identity=identity, passphrase="packpass")
196
+ monkeypatch.undo()
197
+
198
+ header, records, _ = packs.read_pack(blob, passphrase="packpass")
199
+ assert records[0]["text"] == "packed memory"
200
+
201
+
202
+ def test_a_vault_written_now_round_trips():
203
+ """Belt and braces: the current constants agree with themselves."""
204
+ master = crypto.new_key()
205
+ slot = crypto.make_passphrase_slot(master, "correct horse")
206
+ assert crypto.open_slot(slot, "correct horse") == master
207
+ rk, wrapped = crypto.new_record_key(master, "rec-1")
208
+ assert crypto.unwrap_record_key(master, "rec-1", wrapped) == rk
209
+
210
+
211
+ def _vec():
212
+ import numpy as np
213
+ return np.zeros(8, dtype=np.float32)
@@ -1,97 +0,0 @@
1
- """The on-disk format is not branding, and must survive every rename.
2
-
3
- Compartment was called engRAM until 1.15.0. The strings below are AEAD
4
- associated data, KDF context labels and file-format tags that were written
5
- into every vault created before that. They are wire format. Changing one does
6
- not rename anything - it makes previously written bytes undecryptable.
7
-
8
- `engram-keyslot` is the worst of them: it is the associated data used to wrap
9
- the master key. Alter it and the keyslot no longer unseals, which means the
10
- vault cannot be opened again by anyone, with any passphrase, ever. Every
11
- memory in it is gone. `engram-record:`, `engram-record-body:` and
12
- `engram-payload:` are the same hazard one layer down.
13
-
14
- This is not hypothetical. The 1.15.0 rename did a find-and-replace across the
15
- source and rewrote all sixteen of them. It surfaced only because the vault
16
- started reporting itself locked; had the session not broken first, the next
17
- person to run it against a real vault would have found their data unreadable
18
- and no error explaining why.
19
-
20
- So: these constants are frozen. If the product is renamed again, they stay.
21
- """
22
- import pathlib
23
- import re
24
-
25
- import pytest
26
-
27
- SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "compartment"
28
-
29
- # module -> the exact literals that must appear in it, verbatim
30
- FROZEN = {
31
- "crypto.py": [
32
- 'b"engram-keyslot"', # wraps the master key
33
- r'b"\x1f engram-2fa \x1f"', # separates passphrase from keyfile
34
- 'b"engram-record:"', # per-record key wrapping
35
- ],
36
- "store.py": ['b"engram-record-body:"'],
37
- "vaultfile.py": ['b"engram-payload:"', 'b"engram-journal:"'],
38
- "packs.py": ['b"engram-pack:"'],
39
- "session.py": ['"engram-session-v2"', 'b"engram-session:"'],
40
- }
41
-
42
-
43
- @pytest.mark.parametrize("module,literals",
44
- sorted((m, ls) for m, ls in FROZEN.items()))
45
- def test_ondisk_constants_are_frozen(module, literals):
46
- text = (SRC / module).read_text(encoding="utf-8")
47
- for lit in literals:
48
- assert lit in text, (
49
- f"{module} no longer contains {lit}. This is on-disk format, not a "
50
- f"name. Changing it makes every vault written before now "
51
- f"undecryptable - see this module's docstring."
52
- )
53
-
54
-
55
- def test_no_format_constant_carries_the_new_name():
56
- """The mirror of the above: catch the rewrite, not just the absence."""
57
- bad = []
58
- pattern = re.compile(r'b?"[^"]*compartment-(keyslot|record|pack|session|'
59
- r'payload|journal|record-body)[^"]*"')
60
- for module in FROZEN:
61
- for m in pattern.finditer((SRC / module).read_text(encoding="utf-8")):
62
- bad.append(f"{module}: {m.group(0)}")
63
- assert not bad, (
64
- "on-disk constants were renamed to the new product name:\n "
65
- + "\n ".join(bad))
66
-
67
-
68
- def test_keychain_identity_is_frozen():
69
- """The macOS Keychain item is a stored credential, not a label.
70
-
71
- Renaming the service or the account does not migrate the item - it makes
72
- the lookup miss, and the vault then reports itself locked with no hint
73
- that a perfectly good credential is sitting in the Keychain under the old
74
- name. That is what the rename did.
75
- """
76
- text = (SRC / "vault.py").read_text(encoding="utf-8")
77
- assert 'f"engram-vault:{os.path.abspath(path)}"' in text
78
- assert '"-a", "engram",' in text
79
- assert '"-a", "compartment",' not in text
80
- assert "compartment-vault:" not in text
81
-
82
-
83
- def test_keyfile_separator_is_the_value_existing_keyfiles_used():
84
- from compartment.crypto import KEYFILE_SEP
85
- assert KEYFILE_SEP == b"\x1f engram-2fa \x1f"
86
-
87
-
88
- def test_a_vault_written_now_still_round_trips():
89
- """Belt and braces: the constants agree with themselves."""
90
- from compartment.crypto import (make_passphrase_slot, open_slot,
91
- new_key, new_record_key, unwrap_record_key)
92
- master = new_key()
93
- slot = make_passphrase_slot(master, "correct horse")
94
- assert open_slot(slot, "correct horse") == master
95
-
96
- rk, wrapped = new_record_key(master, "rec-1")
97
- assert unwrap_record_key(master, "rec-1", wrapped) == rk
File without changes
File without changes
File without changes
File without changes