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.
- {compartment-2.1 → compartment-2.2}/PKG-INFO +1 -1
- {compartment-2.1 → compartment-2.2}/pyproject.toml +1 -1
- {compartment-2.1 → compartment-2.2}/src/compartment/__init__.py +1 -1
- {compartment-2.1 → compartment-2.2}/src/compartment/crypto.py +87 -14
- {compartment-2.1 → compartment-2.2}/src/compartment/menubar.py +24 -4
- {compartment-2.1 → compartment-2.2}/src/compartment/packs.py +3 -3
- {compartment-2.1 → compartment-2.2}/src/compartment/session.py +25 -12
- {compartment-2.1 → compartment-2.2}/src/compartment/store.py +29 -3
- {compartment-2.1 → compartment-2.2}/src/compartment/vault.py +74 -26
- {compartment-2.1 → compartment-2.2}/src/compartment/vaultfile.py +7 -5
- compartment-2.2/src/compartment/wire.py +130 -0
- {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/PKG-INFO +1 -1
- {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/SOURCES.txt +1 -0
- compartment-2.2/tests/test_ondisk_format.py +213 -0
- compartment-2.1/tests/test_ondisk_format.py +0 -97
- {compartment-2.1 → compartment-2.2}/README.md +0 -0
- {compartment-2.1 → compartment-2.2}/setup.cfg +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/acl.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/audit.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/bench.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/claude_hooks.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/claude_memory.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/cli.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/dash.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/data/hermes-plugin/__init__.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/data/menubar.png +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/data/menubar@2x.png +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/data/starter.mpack +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/data/tray.ico +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/embed.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/home.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/longmemeval.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/offline_guard.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/platforms.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/salience.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/selftest.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/server.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/systray.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment/vindex.py +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/dependency_links.txt +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/entry_points.txt +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/requires.txt +0 -0
- {compartment-2.1 → compartment-2.2}/src/compartment.egg-info/top_level.txt +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_acl_audit_packs.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_claude_hooks_and_integrate.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_claude_memory_and_recent.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_crypto.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_dash.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_instructions.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_macos_bundle.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_menubar.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_offline_selftest_bench.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_platforms.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_relations.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_salience.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_session_lock.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_starter_packs.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_systray.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_tamper.py +0 -0
- {compartment-2.1 → compartment-2.2}/tests/test_twofa.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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=
|
|
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 =
|
|
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=
|
|
171
|
+
"wrapped": seal(wrap_key, master_key, aad=wire.KEYSLOT).hex(),
|
|
141
172
|
}
|
|
142
173
|
|
|
143
174
|
|
|
144
|
-
def
|
|
145
|
-
"""
|
|
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"]
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
862
|
-
|
|
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=
|
|
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.
|
|
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
|
-
|
|
57
|
-
|
|
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=
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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=
|
|
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.
|
|
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
|
-
|
|
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
|
|
826
|
-
"""
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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",
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
|
97
|
+
return wire.payload(vault_id)[0]
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
def _journal_aad(vault_id: str, seq: int) -> bytes:
|
|
101
|
-
return
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{compartment-2.1 → compartment-2.2}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|