compartment 2.1__tar.gz → 2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {compartment-2.1 → compartment-2.3}/PKG-INFO +1 -1
- {compartment-2.1 → compartment-2.3}/pyproject.toml +1 -1
- {compartment-2.1 → compartment-2.3}/src/compartment/__init__.py +1 -1
- compartment-2.3/src/compartment/audit.py +103 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/cli.py +21 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/crypto.py +87 -14
- {compartment-2.1 → compartment-2.3}/src/compartment/menubar.py +24 -4
- {compartment-2.1 → compartment-2.3}/src/compartment/packs.py +3 -3
- {compartment-2.1 → compartment-2.3}/src/compartment/session.py +25 -12
- {compartment-2.1 → compartment-2.3}/src/compartment/store.py +29 -3
- {compartment-2.1 → compartment-2.3}/src/compartment/vault.py +81 -31
- {compartment-2.1 → compartment-2.3}/src/compartment/vaultfile.py +7 -5
- compartment-2.3/src/compartment/wire.py +130 -0
- {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/PKG-INFO +1 -1
- {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/SOURCES.txt +2 -0
- compartment-2.3/tests/test_audit_chain.py +123 -0
- compartment-2.3/tests/test_ondisk_format.py +213 -0
- compartment-2.1/src/compartment/audit.py +0 -54
- compartment-2.1/tests/test_ondisk_format.py +0 -97
- {compartment-2.1 → compartment-2.3}/README.md +0 -0
- {compartment-2.1 → compartment-2.3}/setup.cfg +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/acl.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/bench.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/claude_hooks.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/claude_memory.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/dash.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/data/hermes-plugin/__init__.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/data/menubar.png +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/data/menubar@2x.png +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/data/starter.mpack +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/data/tray.ico +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/embed.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/home.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/longmemeval.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/offline_guard.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/platforms.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/salience.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/selftest.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/server.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/systray.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment/vindex.py +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/dependency_links.txt +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/entry_points.txt +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/requires.txt +0 -0
- {compartment-2.1 → compartment-2.3}/src/compartment.egg-info/top_level.txt +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_acl_audit_packs.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_claude_hooks_and_integrate.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_claude_memory_and_recent.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_crypto.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_dash.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_instructions.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_macos_bundle.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_menubar.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_offline_selftest_bench.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_platforms.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_relations.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_salience.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_session_lock.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_starter_packs.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_systray.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_tamper.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_twofa.py +0 -0
- {compartment-2.1 → compartment-2.3}/tests/test_vault_ops.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: compartment
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3
|
|
4
4
|
Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
|
|
5
5
|
Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
|
|
6
6
|
Project-URL: Source, https://github.com/MaxFreedomPollard/Compartment
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "compartment"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3"
|
|
8
8
|
description = "Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Hash-chained, tamper-evident audit log (stored inside the sealed payload).
|
|
2
|
+
|
|
3
|
+
Each entry's hash covers the previous entry's hash, so any edit, deletion,
|
|
4
|
+
or reordering of history breaks the chain at a detectable point.
|
|
5
|
+
A failure to write the audit entry fails the operation (fail-fast), never
|
|
6
|
+
the other way around.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
GENESIS = "GENESIS"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _entry_hash(prev_hash: str, ts: float, caller: str, op: str, detail: str) -> str:
|
|
18
|
+
body = json.dumps(
|
|
19
|
+
{"prev": prev_hash, "ts": ts, "caller": caller, "op": op, "detail": detail},
|
|
20
|
+
sort_keys=True, separators=(",", ":"),
|
|
21
|
+
).encode()
|
|
22
|
+
return hashlib.sha256(body).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def append(conn, caller: str, op: str, detail: str, ts: float | None = None) -> str:
|
|
26
|
+
"""Add an entry, linked to whatever is currently at the head.
|
|
27
|
+
|
|
28
|
+
`ts` is only passed when replaying a journalled operation, so the entry
|
|
29
|
+
keeps the time it actually happened. It is still linked to the head as it
|
|
30
|
+
stands at replay time, never to the head the original writer saw: that
|
|
31
|
+
head may never have reached disk (read operations are audited in RAM and
|
|
32
|
+
persist only on save), and chaining to an entry nobody else has is what
|
|
33
|
+
leaves a dangling link no future verify can resolve."""
|
|
34
|
+
row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
|
|
35
|
+
prev = row["hash"] if row else GENESIS
|
|
36
|
+
ts = time.time() if ts is None else ts
|
|
37
|
+
h = _entry_hash(prev, ts, caller, op, detail)
|
|
38
|
+
conn.execute(
|
|
39
|
+
"INSERT INTO audit (ts, caller, op, detail, prev_hash, hash) VALUES (?,?,?,?,?,?)",
|
|
40
|
+
(ts, caller, op, detail, prev, h),
|
|
41
|
+
)
|
|
42
|
+
return h
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def head(conn) -> str:
|
|
46
|
+
row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
|
|
47
|
+
return row["hash"] if row else GENESIS
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def verify(conn) -> tuple[bool, int, str]:
|
|
51
|
+
"""Walk the chain. Returns (ok, entries_checked, message)."""
|
|
52
|
+
prev = GENESIS
|
|
53
|
+
n = 0
|
|
54
|
+
for row in conn.execute("SELECT * FROM audit ORDER BY seq"):
|
|
55
|
+
if row["prev_hash"] != prev:
|
|
56
|
+
return False, n, f"chain break at seq {row['seq']}: prev_hash mismatch"
|
|
57
|
+
want = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
|
|
58
|
+
if row["hash"] != want:
|
|
59
|
+
return False, n, f"chain break at seq {row['seq']}: entry hash mismatch"
|
|
60
|
+
prev = row["hash"]
|
|
61
|
+
n += 1
|
|
62
|
+
return True, n, f"audit chain intact ({n} entries)"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def relink(conn) -> tuple[int, int | None]:
|
|
66
|
+
"""Re-link entries whose prev_hash points at something not in the log.
|
|
67
|
+
|
|
68
|
+
Only for repairing damage done by builds before this one, which replayed a
|
|
69
|
+
journalled entry with the head its original writer saw rather than the head
|
|
70
|
+
the log actually has. The result was a permanent dangling link: verify
|
|
71
|
+
stopped at it and never reported anything after it again.
|
|
72
|
+
|
|
73
|
+
What this does NOT do is paper over an edit. Every entry's own hash covers
|
|
74
|
+
its content, so content tampering is caught by the self-hash check and this
|
|
75
|
+
refuses to touch a log that fails it. Only the links are rebuilt, and only
|
|
76
|
+
forward from the first break. Nothing is deleted, reordered or reworded:
|
|
77
|
+
ts, caller, op and detail are exactly what they were.
|
|
78
|
+
|
|
79
|
+
Returns (entries relinked, seq of the first break) - (0, None) if intact."""
|
|
80
|
+
rows = conn.execute("SELECT * FROM audit ORDER BY seq").fetchall()
|
|
81
|
+
for row in rows:
|
|
82
|
+
want = _entry_hash(row["prev_hash"], row["ts"], row["caller"], row["op"],
|
|
83
|
+
row["detail"])
|
|
84
|
+
if row["hash"] != want:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"audit entry seq {row['seq']} does not hash to its own content. "
|
|
87
|
+
"That is content tampering, not a dangling link, and relinking "
|
|
88
|
+
"would destroy the evidence. Refusing.")
|
|
89
|
+
|
|
90
|
+
prev, first, changed = GENESIS, None, 0
|
|
91
|
+
for row in rows:
|
|
92
|
+
if row["prev_hash"] != prev:
|
|
93
|
+
if first is None:
|
|
94
|
+
first = row["seq"]
|
|
95
|
+
if first is not None: # rewrite everything after it
|
|
96
|
+
h = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
|
|
97
|
+
conn.execute("UPDATE audit SET prev_hash = ?, hash = ? WHERE seq = ?",
|
|
98
|
+
(prev, h, row["seq"]))
|
|
99
|
+
changed += 1
|
|
100
|
+
prev = h
|
|
101
|
+
else:
|
|
102
|
+
prev = row["hash"]
|
|
103
|
+
return changed, first
|
|
@@ -468,6 +468,25 @@ def cmd_audit(args) -> None:
|
|
|
468
468
|
sys.exit(2)
|
|
469
469
|
|
|
470
470
|
|
|
471
|
+
def cmd_audit_repair(args) -> None:
|
|
472
|
+
v = _open_vault(args)
|
|
473
|
+
try:
|
|
474
|
+
changed, first = audit.relink(v.db.conn)
|
|
475
|
+
except ValueError as exc:
|
|
476
|
+
_die(str(exc))
|
|
477
|
+
if not changed:
|
|
478
|
+
_print({"ok": True, "relinked": 0, "message": "audit chain already intact"})
|
|
479
|
+
return
|
|
480
|
+
v._audit_and_capture("user", "audit-repair",
|
|
481
|
+
f"relinked {changed} entries from seq {first}")
|
|
482
|
+
v.save()
|
|
483
|
+
ok, n, msg = audit.verify(v.db.conn)
|
|
484
|
+
_print({"ok": ok, "relinked": changed, "first_break_at_seq": first,
|
|
485
|
+
"entries": n, "message": msg, "head": audit.head(v.db.conn)})
|
|
486
|
+
if not ok:
|
|
487
|
+
sys.exit(2)
|
|
488
|
+
|
|
489
|
+
|
|
471
490
|
def cmd_verify(args) -> None:
|
|
472
491
|
loaded = read_vault_file(args.vault) # structure + format checks
|
|
473
492
|
out = {"vault": args.vault, "format": "ok",
|
|
@@ -1165,6 +1184,8 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
1165
1184
|
pa_sub = pa.add_subparsers(dest="audit_cmd", required=True)
|
|
1166
1185
|
p = pa_sub.add_parser("verify", help="verify the hash chain")
|
|
1167
1186
|
p.set_defaults(fn=cmd_audit)
|
|
1187
|
+
p = pa_sub.add_parser("repair", help="re-link a chain broken by an older build")
|
|
1188
|
+
p.set_defaults(fn=cmd_audit_repair)
|
|
1168
1189
|
|
|
1169
1190
|
p = sub.add_parser("verify", help="check vault structure + signed manifest")
|
|
1170
1191
|
p.set_defaults(fn=cmd_verify)
|
|
@@ -19,6 +19,8 @@ from nacl.bindings import (
|
|
|
19
19
|
crypto_aead_xchacha20poly1305_ietf_encrypt,
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
+
from . import wire
|
|
23
|
+
|
|
22
24
|
KEY_LEN = 32
|
|
23
25
|
NONCE_LEN = 24
|
|
24
26
|
|
|
@@ -81,6 +83,35 @@ def unseal(key: bytes, blob: bytes, aad: bytes = b"") -> bytes:
|
|
|
81
83
|
) from exc
|
|
82
84
|
|
|
83
85
|
|
|
86
|
+
def unseal_which(key: bytes, blob: bytes, aad: bytes,
|
|
87
|
+
legacy: tuple[bytes, ...] = ()) -> tuple[bytes, bytes]:
|
|
88
|
+
"""unseal(), accepting associated data written by an older build.
|
|
89
|
+
|
|
90
|
+
Returns (plaintext, the aad that actually authenticated), so a caller can
|
|
91
|
+
tell a migrated blob from one still carrying a legacy label and rewrite it.
|
|
92
|
+
|
|
93
|
+
The current spelling is tried first, so anything already migrated costs
|
|
94
|
+
exactly one AEAD open. Legacy spellings are tried in order only after that
|
|
95
|
+
fails, which is what lets a pre-rename vault open at all. If none of them
|
|
96
|
+
authenticate the data really is tampered with, or the key really is wrong,
|
|
97
|
+
and the original error is raised."""
|
|
98
|
+
try:
|
|
99
|
+
return unseal(key, blob, aad=aad), aad
|
|
100
|
+
except TamperError:
|
|
101
|
+
for old in legacy:
|
|
102
|
+
try:
|
|
103
|
+
return unseal(key, blob, aad=old), old
|
|
104
|
+
except TamperError:
|
|
105
|
+
continue
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def unseal_any(key: bytes, blob: bytes, aad: bytes,
|
|
110
|
+
legacy: tuple[bytes, ...] = ()) -> bytes:
|
|
111
|
+
"""unseal_which() when the caller does not care which label was used."""
|
|
112
|
+
return unseal_which(key, blob, aad, legacy)[0]
|
|
113
|
+
|
|
114
|
+
|
|
84
115
|
def wipe(buf: bytearray) -> None:
|
|
85
116
|
"""Best-effort zeroization (Python cannot guarantee no copies exist)."""
|
|
86
117
|
for i in range(len(buf)):
|
|
@@ -99,7 +130,7 @@ def make_passphrase_slot(master_key: bytes, passphrase: str, kdf: dict | None =
|
|
|
99
130
|
"type": "passphrase",
|
|
100
131
|
"kdf": kdf,
|
|
101
132
|
"salt": salt.hex(),
|
|
102
|
-
"wrapped": seal(wrap_key, master_key, aad=
|
|
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,
|