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