methodproof 0.4.4__tar.gz → 0.5.0__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.
- {methodproof-0.4.4 → methodproof-0.5.0}/PKG-INFO +3 -5
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/__init__.py +1 -1
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/base.py +19 -5
- methodproof-0.5.0/methodproof/binding.py +28 -0
- methodproof-0.5.0/methodproof/bip39.py +37 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/cli.py +179 -4
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/config.py +4 -1
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/install.py +20 -8
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/integrity.py +6 -3
- methodproof-0.5.0/methodproof/kdf.py +25 -0
- methodproof-0.5.0/methodproof/keychain.py +71 -0
- methodproof-0.5.0/methodproof/lock.py +41 -0
- methodproof-0.5.0/methodproof/migrate_db.py +42 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/store.py +17 -4
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/sync.py +8 -2
- methodproof-0.5.0/methodproof/wordlist.py +2052 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/pyproject.toml +2 -4
- methodproof-0.5.0/tests/test_security.py +370 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/.github/workflows/ci.yml +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/.gitignore +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/CHANGELOG.md +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/LICENSE +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/README.md +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/__main__.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/music.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/terminal.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/watcher.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/analysis.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/bridge.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/crypto.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/graph.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hook.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/claude_code.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/claude_code.sh +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/gemini_hook.sh +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/live.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/mcp.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/proxy.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/proxy_daemon.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/repos.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/viewer.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/test_windows_compat.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/tests/__init__.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_analysis.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_graph.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_hooks.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_live.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_store.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_wrappers.py +0 -0
- {methodproof-0.4.4 → methodproof-0.5.0}/uv.lock +0 -0
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: methodproof
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: See how you code. Capture and visualize your engineering process.
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: cryptography>=43.0
|
|
9
|
+
Requires-Dist: keyring>=25.0
|
|
8
10
|
Requires-Dist: watchdog>=4.0
|
|
9
11
|
Requires-Dist: websocket-client>=1.7
|
|
10
|
-
Provides-Extra: e2e
|
|
11
|
-
Requires-Dist: cryptography>=43.0; extra == 'e2e'
|
|
12
12
|
Provides-Extra: proxy
|
|
13
13
|
Requires-Dist: mitmproxy>=10.0; extra == 'proxy'
|
|
14
|
-
Provides-Extra: signing
|
|
15
|
-
Requires-Dist: cryptography>=43.0; extra == 'signing'
|
|
16
14
|
Description-Content-Type: text/markdown
|
|
17
15
|
|
|
18
16
|
<p align="center">
|
|
@@ -19,6 +19,8 @@ _buffer: list[dict[str, Any]] = []
|
|
|
19
19
|
_FLUSH_SIZE = 50
|
|
20
20
|
_MAX_RETRIES = 3
|
|
21
21
|
_prev_hash = "genesis"
|
|
22
|
+
_account_id = ""
|
|
23
|
+
_journal_mode = False
|
|
22
24
|
|
|
23
25
|
# Maps event types to the capture category that gates them
|
|
24
26
|
_EVENT_GATES: dict[str, str] = {
|
|
@@ -74,21 +76,33 @@ _FIELD_GATES: dict[str, list[tuple[str, str]]] = {
|
|
|
74
76
|
"code_capture": [("file_edit", "diff"), ("git_commit", "diff")],
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
|
|
80
|
+
def _load_encryption_key(cfg: dict) -> bytes | None:
|
|
81
|
+
"""Load db_key from keychain (preferred) or legacy e2e_key config."""
|
|
82
|
+
account_id = cfg.get("account_id", "")
|
|
83
|
+
if account_id and cfg.get("master_key_fingerprint"):
|
|
84
|
+
from methodproof.keychain import load_secret
|
|
85
|
+
from methodproof.kdf import derive_master, derive_db_key
|
|
86
|
+
master_entropy = load_secret(account_id)
|
|
87
|
+
if master_entropy:
|
|
88
|
+
master = derive_master(master_entropy)
|
|
89
|
+
return derive_db_key(master, account_id)
|
|
90
|
+
raw = cfg.get("e2e_key", "")
|
|
91
|
+
return bytes.fromhex(raw) if raw else None
|
|
78
92
|
|
|
79
93
|
|
|
80
94
|
def init(session_id: str, live: bool = False) -> None:
|
|
81
|
-
global _session_id, _initialized, _e2e_key, _capture, _live_mode, _prev_hash, _journal_mode
|
|
95
|
+
global _session_id, _initialized, _e2e_key, _capture, _live_mode, _prev_hash, _journal_mode, _account_id
|
|
82
96
|
_session_id = session_id
|
|
83
97
|
_initialized = True
|
|
84
98
|
_live_mode = live
|
|
85
99
|
_prev_hash = "genesis"
|
|
86
100
|
from methodproof import config
|
|
87
101
|
cfg = config.load()
|
|
88
|
-
|
|
89
|
-
_e2e_key = bytes.fromhex(raw) if raw else None
|
|
102
|
+
_e2e_key = _load_encryption_key(cfg)
|
|
90
103
|
_capture = cfg.get("capture", {})
|
|
91
104
|
_journal_mode = cfg.get("journal_mode", False)
|
|
105
|
+
_account_id = cfg.get("account_id", "")
|
|
92
106
|
|
|
93
107
|
|
|
94
108
|
def log(level: str, event: str, **kw: object) -> None:
|
|
@@ -133,7 +147,7 @@ def emit(event_type: str, metadata: dict[str, Any]) -> None:
|
|
|
133
147
|
entry["metadata"] = encrypt_metadata(dict(entry["metadata"]), _e2e_key)
|
|
134
148
|
global _prev_hash
|
|
135
149
|
from methodproof.integrity import compute_event_hash
|
|
136
|
-
entry["_chain_hash"] = compute_event_hash(entry, _prev_hash)
|
|
150
|
+
entry["_chain_hash"] = compute_event_hash(entry, _prev_hash, _account_id)
|
|
137
151
|
_prev_hash = entry["_chain_hash"]
|
|
138
152
|
if _live_mode:
|
|
139
153
|
from methodproof import live as live_mod
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Session binding — HMAC ties sessions to account + device."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
import time as _time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compute_binding(bind_key: bytes, session_id: str, account_id: str,
|
|
12
|
+
device_id: str, created_at: float) -> str:
|
|
13
|
+
"""HMAC-SHA256 binding signature for a session."""
|
|
14
|
+
msg = f"{session_id}:{account_id}:{device_id}:{created_at}".encode()
|
|
15
|
+
return hmac.new(bind_key, msg, hashlib.sha256).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compute_device_id() -> str:
|
|
19
|
+
"""Deterministic device fingerprint — hash of stable machine attributes."""
|
|
20
|
+
parts = [
|
|
21
|
+
platform.node(),
|
|
22
|
+
platform.system(),
|
|
23
|
+
platform.machine(),
|
|
24
|
+
platform.python_version(),
|
|
25
|
+
_time.tzname[0],
|
|
26
|
+
str(os.cpu_count()),
|
|
27
|
+
]
|
|
28
|
+
return hashlib.sha256(":".join(parts).encode()).hexdigest()[:16]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""BIP39 mnemonic encoding — 128-bit entropy to 12-word recovery phrase."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def entropy_to_phrase(entropy: bytes) -> str:
|
|
7
|
+
"""Encode 16 bytes (128 bits) as 12 BIP39 words with checksum."""
|
|
8
|
+
from methodproof.wordlist import WORDS
|
|
9
|
+
if len(entropy) != 16:
|
|
10
|
+
raise ValueError("Expected 16 bytes of entropy")
|
|
11
|
+
checksum = hashlib.sha256(entropy).digest()[0] >> 4 # 4 bits
|
|
12
|
+
bits = int.from_bytes(entropy) << 4 | checksum # 132 bits
|
|
13
|
+
words = []
|
|
14
|
+
for _ in range(12):
|
|
15
|
+
words.append(WORDS[bits & 0x7FF])
|
|
16
|
+
bits >>= 11
|
|
17
|
+
return " ".join(reversed(words))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def phrase_to_entropy(phrase: str) -> bytes:
|
|
21
|
+
"""Decode 12 BIP39 words back to 16 bytes of entropy."""
|
|
22
|
+
from methodproof.wordlist import WORDS
|
|
23
|
+
word_list = phrase.strip().lower().split()
|
|
24
|
+
if len(word_list) != 12:
|
|
25
|
+
raise ValueError("Expected 12 words")
|
|
26
|
+
index = {w: i for i, w in enumerate(WORDS)}
|
|
27
|
+
bits = 0
|
|
28
|
+
for word in word_list:
|
|
29
|
+
if word not in index:
|
|
30
|
+
raise ValueError(f"Unknown word: {word}")
|
|
31
|
+
bits = (bits << 11) | index[word]
|
|
32
|
+
checksum = bits & 0xF
|
|
33
|
+
entropy = (bits >> 4).to_bytes(16)
|
|
34
|
+
expected = hashlib.sha256(entropy).digest()[0] >> 4
|
|
35
|
+
if checksum != expected:
|
|
36
|
+
raise ValueError("Invalid checksum")
|
|
37
|
+
return entropy
|
|
@@ -89,7 +89,7 @@ def _print_intro() -> None:
|
|
|
89
89
|
print(f" {D}See how you code. Prove how you build.{R}")
|
|
90
90
|
print(f"\n {bar}\n")
|
|
91
91
|
print(f" ┌────────────────────────────────────────────────┐")
|
|
92
|
-
print(f" │ {Y}SHARE{R} push · publish · live
|
|
92
|
+
print(f" │ {Y}SHARE{R} push · publish · anonymous · live │")
|
|
93
93
|
print(f" ├────────────────────────────────────────────────┤")
|
|
94
94
|
print(f" │ {C}GRAPH{R} knowledge graph · moments · edges │")
|
|
95
95
|
print(f" ├────────────────────────────────────────────────┤")
|
|
@@ -101,6 +101,7 @@ def _print_intro() -> None:
|
|
|
101
101
|
print(f" {D}3.{R} {G}mp stop{R} build your process graph")
|
|
102
102
|
print(f" {D}4.{R} {G}mp push{R} upload to your profile")
|
|
103
103
|
print()
|
|
104
|
+
print(f" {D}Publish anonymously (Pro) or contribute to research.{R}")
|
|
104
105
|
print(f" {D}All data stays local until you push.{R}\n")
|
|
105
106
|
|
|
106
107
|
|
|
@@ -110,7 +111,7 @@ def _print_intro_plain() -> None:
|
|
|
110
111
|
print(" See how you code. Prove how you build.")
|
|
111
112
|
print("\n ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
|
112
113
|
print(" ┌────────────────────────────────────────────────┐")
|
|
113
|
-
print(" │ SHARE push · publish · live
|
|
114
|
+
print(" │ SHARE push · publish · anonymous · live │")
|
|
114
115
|
print(" ├────────────────────────────────────────────────┤")
|
|
115
116
|
print(" │ GRAPH knowledge graph · moments · edges │")
|
|
116
117
|
print(" ├────────────────────────────────────────────────┤")
|
|
@@ -122,6 +123,7 @@ def _print_intro_plain() -> None:
|
|
|
122
123
|
print(" 3. mp stop build your process graph")
|
|
123
124
|
print(" 4. mp push upload to your profile")
|
|
124
125
|
print()
|
|
126
|
+
print(" Publish anonymously (Pro) or contribute to research.")
|
|
125
127
|
print(" All data stays local until you push.\n")
|
|
126
128
|
|
|
127
129
|
|
|
@@ -453,6 +455,7 @@ def _print_commands() -> None:
|
|
|
453
455
|
print(f" {_W}SHARE{R}")
|
|
454
456
|
print(f" {_Y}mp push{R} {_D}[id]{R} Upload privately to your account")
|
|
455
457
|
print(f" {_Y}mp publish{R} {_D}[id]{R} Make session public (redaction applied)")
|
|
458
|
+
print(f" {_Y}mp publish --anonymous{R} Public but identity hidden {_D}(Pro){R}")
|
|
456
459
|
print(f" {_Y}mp tag{R} {_D}<id> <tags>{R} Tag a session")
|
|
457
460
|
print()
|
|
458
461
|
print(f" {_W}EXTENSION{R}")
|
|
@@ -463,6 +466,8 @@ def _print_commands() -> None:
|
|
|
463
466
|
print(f" {_W}ACCOUNT{R}")
|
|
464
467
|
print(f" {_M}mp login{R} Connect to platform (opens browser)")
|
|
465
468
|
print(f" {_M}mp consent{R} Change capture, research, and redaction settings")
|
|
469
|
+
print(f" {_M}mp lock{R} Destroy local encryption key {_D}(reversible){R}")
|
|
470
|
+
print(f" {_M}mp lock --purge{R} Delete all local data {_D}(irreversible){R}")
|
|
466
471
|
print(f" {_M}mp reset{R} Clear login and consent (keeps sessions)")
|
|
467
472
|
print(f" {_M}mp delete{R} {_D}<id>{R} Delete a session and all its data")
|
|
468
473
|
print(f" {_M}mp update{R} Update to the latest version")
|
|
@@ -487,6 +492,7 @@ def _print_commands_plain() -> None:
|
|
|
487
492
|
print(" SHARE")
|
|
488
493
|
print(" mp push [id] Upload privately to your account")
|
|
489
494
|
print(" mp publish [id] Make session public (redaction applied)")
|
|
495
|
+
print(" mp publish --anonymous Public but identity hidden (Pro)")
|
|
490
496
|
print(" mp tag <id> <tags> Tag a session")
|
|
491
497
|
print()
|
|
492
498
|
print(" EXTENSION")
|
|
@@ -497,6 +503,8 @@ def _print_commands_plain() -> None:
|
|
|
497
503
|
print(" ACCOUNT")
|
|
498
504
|
print(" mp login Connect to platform (opens browser)")
|
|
499
505
|
print(" mp consent Change capture, research, and redaction settings")
|
|
506
|
+
print(" mp lock Destroy local encryption key (reversible)")
|
|
507
|
+
print(" mp lock --purge Delete all local data (irreversible)")
|
|
500
508
|
print(" mp reset Clear login and consent (keeps sessions)")
|
|
501
509
|
print(" mp delete <id> Delete a session and all its data")
|
|
502
510
|
print(" mp update Update to the latest version")
|
|
@@ -605,6 +613,25 @@ def cmd_uninstall(args: argparse.Namespace) -> None:
|
|
|
605
613
|
print(" Restart your shell to clear hooks.\n")
|
|
606
614
|
|
|
607
615
|
|
|
616
|
+
def cmd_lock(args: argparse.Namespace) -> None:
|
|
617
|
+
"""Destroy local key access. --purge deletes DB entirely."""
|
|
618
|
+
cfg = config.load()
|
|
619
|
+
account_id = cfg.get("account_id", "")
|
|
620
|
+
if not account_id:
|
|
621
|
+
print("No account linked. Nothing to lock.")
|
|
622
|
+
return
|
|
623
|
+
purge = getattr(args, "purge", False)
|
|
624
|
+
if not args.force:
|
|
625
|
+
action = "DELETE all local data" if purge else "lock encrypted fields"
|
|
626
|
+
answer = input(f" {action}? [y/N]: ").strip().lower()
|
|
627
|
+
if answer not in ("y", "yes"):
|
|
628
|
+
print(" Cancelled.")
|
|
629
|
+
return
|
|
630
|
+
from methodproof.lock import lock
|
|
631
|
+
lock(account_id, purge=purge)
|
|
632
|
+
print(" Done.\n")
|
|
633
|
+
|
|
634
|
+
|
|
608
635
|
def cmd_reset(args: argparse.Namespace) -> None:
|
|
609
636
|
"""Wipe login credentials and consent config. Sessions and hooks kept."""
|
|
610
637
|
cfg = config.load()
|
|
@@ -690,6 +717,122 @@ def cmd_journal(args: argparse.Namespace) -> None:
|
|
|
690
717
|
print("Usage: methodproof journal [on|off|status]")
|
|
691
718
|
|
|
692
719
|
|
|
720
|
+
def _decode_jwt_claims(token: str) -> dict:
|
|
721
|
+
"""Extract claims from JWT payload without verification (auth already done server-side)."""
|
|
722
|
+
import base64
|
|
723
|
+
parts = token.split(".")
|
|
724
|
+
if len(parts) != 3:
|
|
725
|
+
return {}
|
|
726
|
+
payload = parts[1] + "=" * (-len(parts[1]) % 4) # pad base64
|
|
727
|
+
return json.loads(base64.urlsafe_b64decode(payload))
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _require_auth(cfg: dict) -> str:
|
|
731
|
+
"""Ensure valid auth. Returns account_id. Exits on failure."""
|
|
732
|
+
token = cfg.get("token", "")
|
|
733
|
+
if not token:
|
|
734
|
+
print("Login required. Run `mp login` first.")
|
|
735
|
+
sys.exit(1)
|
|
736
|
+
claims = _decode_jwt_claims(token)
|
|
737
|
+
account_id = claims.get("user_id", "")
|
|
738
|
+
if not account_id:
|
|
739
|
+
print("Invalid token. Run `mp login` to re-authenticate.")
|
|
740
|
+
sys.exit(1)
|
|
741
|
+
# Check expiry — attempt refresh if expired
|
|
742
|
+
exp = claims.get("exp", 0)
|
|
743
|
+
if exp and time.time() > exp:
|
|
744
|
+
from methodproof.sync import _refresh_token
|
|
745
|
+
pair = _refresh_token(cfg["api_url"], cfg.get("refresh_token", ""))
|
|
746
|
+
if pair:
|
|
747
|
+
cfg["token"], cfg["refresh_token"] = pair
|
|
748
|
+
cfg["last_auth_at"] = time.time()
|
|
749
|
+
cfg["account_id"] = account_id
|
|
750
|
+
config.save(cfg)
|
|
751
|
+
return account_id
|
|
752
|
+
# Offline grace — allow if last auth was within 24h
|
|
753
|
+
if time.time() - cfg.get("last_auth_at", 0) < 86400:
|
|
754
|
+
return account_id
|
|
755
|
+
print("Session expired. Run `mp login` to re-authenticate.")
|
|
756
|
+
sys.exit(1)
|
|
757
|
+
cfg["last_auth_at"] = time.time()
|
|
758
|
+
cfg["account_id"] = account_id
|
|
759
|
+
config.save(cfg)
|
|
760
|
+
return account_id
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _setup_master_key(cfg: dict) -> None:
|
|
764
|
+
"""Generate or recover master key on first login. Shows recovery phrase once."""
|
|
765
|
+
account_id = cfg.get("account_id", "")
|
|
766
|
+
if not account_id:
|
|
767
|
+
return
|
|
768
|
+
from methodproof.keychain import has_secret, store_secret, load_secret
|
|
769
|
+
if has_secret(account_id):
|
|
770
|
+
# Already set up — ensure fingerprint is in config
|
|
771
|
+
if not cfg.get("master_key_fingerprint"):
|
|
772
|
+
from methodproof.kdf import derive_master, derive_db_key
|
|
773
|
+
from methodproof.crypto import fingerprint
|
|
774
|
+
master = derive_master(load_secret(account_id))
|
|
775
|
+
cfg["master_key_fingerprint"] = fingerprint(derive_db_key(master, account_id))
|
|
776
|
+
config.save(cfg)
|
|
777
|
+
return
|
|
778
|
+
|
|
779
|
+
# Check if user has a recovery phrase (returning user, new device)
|
|
780
|
+
if cfg.get("master_key_fingerprint"):
|
|
781
|
+
_recover_master_key(cfg, account_id)
|
|
782
|
+
return
|
|
783
|
+
|
|
784
|
+
# First time — generate entropy, show recovery phrase
|
|
785
|
+
entropy = os.urandom(16)
|
|
786
|
+
from methodproof.bip39 import entropy_to_phrase
|
|
787
|
+
phrase = entropy_to_phrase(entropy)
|
|
788
|
+
store_secret(account_id, entropy)
|
|
789
|
+
|
|
790
|
+
from methodproof.kdf import derive_master, derive_db_key
|
|
791
|
+
from methodproof.crypto import fingerprint
|
|
792
|
+
master = derive_master(entropy)
|
|
793
|
+
cfg["master_key_fingerprint"] = fingerprint(derive_db_key(master, account_id))
|
|
794
|
+
config.save(cfg)
|
|
795
|
+
|
|
796
|
+
W = "\033[1;97m"
|
|
797
|
+
Y = "\033[93m"
|
|
798
|
+
D = "\033[90m"
|
|
799
|
+
R = _RESET
|
|
800
|
+
print(f" ┌──────────────────────────────────────────────────┐")
|
|
801
|
+
print(f" │ {W}RECOVERY PHRASE — WRITE THIS DOWN{R} │")
|
|
802
|
+
print(f" │ │")
|
|
803
|
+
print(f" │ {Y}{phrase}{R}")
|
|
804
|
+
print(f" │ │")
|
|
805
|
+
print(f" │ {D}This is the only way to recover your data{R} │")
|
|
806
|
+
print(f" │ {D}on a new device. Store it somewhere safe.{R} │")
|
|
807
|
+
print(f" └──────────────────────────────────────────────────┘\n")
|
|
808
|
+
|
|
809
|
+
# Encrypt any existing plaintext events
|
|
810
|
+
db_key = derive_db_key(master, account_id)
|
|
811
|
+
from methodproof.migrate_db import migrate_encrypt
|
|
812
|
+
count = migrate_encrypt(db_key)
|
|
813
|
+
if count:
|
|
814
|
+
print(f" Encrypted {count} existing events.\n")
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _recover_master_key(cfg: dict, account_id: str) -> None:
|
|
818
|
+
"""Prompt for recovery phrase to restore master key on new device."""
|
|
819
|
+
print("\n Master key not found on this device.")
|
|
820
|
+
print(" Enter your 12-word recovery phrase to restore access.\n")
|
|
821
|
+
phrase = input(" Recovery phrase: ").strip()
|
|
822
|
+
if not phrase:
|
|
823
|
+
print(" Skipped. Encrypted session data will be inaccessible.")
|
|
824
|
+
return
|
|
825
|
+
from methodproof.bip39 import phrase_to_entropy
|
|
826
|
+
try:
|
|
827
|
+
entropy = phrase_to_entropy(phrase)
|
|
828
|
+
except ValueError as e:
|
|
829
|
+
print(f" Invalid phrase: {e}")
|
|
830
|
+
return
|
|
831
|
+
from methodproof.keychain import store_secret
|
|
832
|
+
store_secret(account_id, entropy)
|
|
833
|
+
print(" Master key restored.\n")
|
|
834
|
+
|
|
835
|
+
|
|
693
836
|
def _is_daemon_alive() -> bool:
|
|
694
837
|
"""Check if the recording daemon is still running."""
|
|
695
838
|
if not PIDFILE.exists():
|
|
@@ -723,6 +866,8 @@ def cmd_start(args: argparse.Namespace) -> None:
|
|
|
723
866
|
print("Run `methodproof init` first.")
|
|
724
867
|
sys.exit(1)
|
|
725
868
|
|
|
869
|
+
account_id = _require_auth(cfg)
|
|
870
|
+
|
|
726
871
|
# Check for new consent categories before recording
|
|
727
872
|
capture = cfg.get("capture", {})
|
|
728
873
|
new_cats = (set(config.STANDARD_CATEGORIES) | {"code_capture"}) - set(capture.keys())
|
|
@@ -738,11 +883,33 @@ def cmd_start(args: argparse.Namespace) -> None:
|
|
|
738
883
|
repo_url = args.repo or repos.detect_repo(watch_dir)
|
|
739
884
|
tags = args.tags.split(",") if args.tags else []
|
|
740
885
|
visibility = "public" if args.public else "private"
|
|
741
|
-
|
|
886
|
+
from methodproof.binding import compute_binding, compute_device_id
|
|
887
|
+
device_id = compute_device_id()
|
|
888
|
+
# Compute session binding if master key is available
|
|
889
|
+
binding = ""
|
|
890
|
+
if cfg.get("master_key_fingerprint") and account_id:
|
|
891
|
+
from methodproof.keychain import load_secret
|
|
892
|
+
from methodproof.kdf import derive_master, derive_bind_key
|
|
893
|
+
entropy = load_secret(account_id)
|
|
894
|
+
if entropy:
|
|
895
|
+
master = derive_master(entropy)
|
|
896
|
+
bind_key = derive_bind_key(master, account_id)
|
|
897
|
+
binding = compute_binding(bind_key, sid, account_id, device_id, time.time())
|
|
898
|
+
store.create_session(sid, watch_dir, repo_url, json.dumps(tags), visibility,
|
|
899
|
+
account_id, binding, device_id)
|
|
742
900
|
cfg["active_session"] = sid
|
|
743
901
|
config.save(cfg)
|
|
744
902
|
PIDFILE.write_text(str(os.getpid()))
|
|
745
903
|
|
|
904
|
+
# Temporal anchor — server-signed timestamp (best-effort, skip if offline)
|
|
905
|
+
if cfg.get("token"):
|
|
906
|
+
try:
|
|
907
|
+
from methodproof.sync import _request
|
|
908
|
+
anchor = _request("POST", f"/sessions/{sid}/anchor", cfg["api_url"], cfg["token"])
|
|
909
|
+
store.update_anchor(sid, anchor["anchor_ts"], anchor["signature"])
|
|
910
|
+
except Exception:
|
|
911
|
+
pass # offline — no anchor, lower trust score
|
|
912
|
+
|
|
746
913
|
from methodproof.agents import base
|
|
747
914
|
live_ok = False
|
|
748
915
|
capture = cfg.get("capture", {})
|
|
@@ -1009,8 +1176,13 @@ def cmd_login(args: argparse.Namespace) -> None:
|
|
|
1009
1176
|
cfg["token"] = poll["token"]
|
|
1010
1177
|
cfg["refresh_token"] = poll.get("refresh_token", "")
|
|
1011
1178
|
cfg["api_url"] = api
|
|
1179
|
+
# Extract account_id and persist auth timestamp
|
|
1180
|
+
claims = _decode_jwt_claims(poll["token"])
|
|
1181
|
+
cfg["account_id"] = claims.get("user_id", "")
|
|
1182
|
+
cfg["last_auth_at"] = time.time()
|
|
1012
1183
|
config.save(cfg)
|
|
1013
1184
|
print(" done.\n")
|
|
1185
|
+
_setup_master_key(cfg)
|
|
1014
1186
|
print("Logged in. Run `methodproof push` to upload sessions.")
|
|
1015
1187
|
return
|
|
1016
1188
|
except Exception:
|
|
@@ -1443,6 +1615,9 @@ def main() -> None:
|
|
|
1443
1615
|
help="Enable auto-update before each mp start (recommended)")
|
|
1444
1616
|
up_auto.add_argument("--no-auto", dest="auto", action="store_false",
|
|
1445
1617
|
help="Disable auto-update")
|
|
1618
|
+
lk = sub.add_parser("lock", help="Destroy local encryption key (reversible with recovery phrase)")
|
|
1619
|
+
lk.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
1620
|
+
lk.add_argument("--purge", action="store_true", help="Also delete the entire database (irreversible)")
|
|
1446
1621
|
rs = sub.add_parser("reset", help="Clear login and consent settings (keeps sessions and hooks)")
|
|
1447
1622
|
rs.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
1448
1623
|
un = sub.add_parser("uninstall", help="Remove all hooks, data, and config")
|
|
@@ -1474,7 +1649,7 @@ def main() -> None:
|
|
|
1474
1649
|
"view": cmd_view, "log": cmd_log, "login": cmd_login,
|
|
1475
1650
|
"push": cmd_push, "tag": cmd_tag, "publish": cmd_publish,
|
|
1476
1651
|
"delete": cmd_delete, "review": cmd_review, "consent": cmd_consent,
|
|
1477
|
-
"update": cmd_update, "reset": cmd_reset, "uninstall": cmd_uninstall,
|
|
1652
|
+
"update": cmd_update, "lock": cmd_lock, "reset": cmd_reset, "uninstall": cmd_uninstall,
|
|
1478
1653
|
"extension": cmd_extension,
|
|
1479
1654
|
"journal": cmd_journal,
|
|
1480
1655
|
"intro": lambda _: _print_intro(),
|
|
@@ -18,7 +18,8 @@ _DEFAULTS: dict[str, Any] = {
|
|
|
18
18
|
"refresh_token": "",
|
|
19
19
|
"email": "",
|
|
20
20
|
"active_session": None,
|
|
21
|
-
"e2e_key": "",
|
|
21
|
+
"e2e_key": "", # legacy — new installs use keychain-derived keys
|
|
22
|
+
"master_key_fingerprint": "",
|
|
22
23
|
"capture": {
|
|
23
24
|
"terminal_commands": True,
|
|
24
25
|
"command_output": True,
|
|
@@ -36,6 +37,8 @@ _DEFAULTS: dict[str, Any] = {
|
|
|
36
37
|
"journal_mode": False,
|
|
37
38
|
"journal_credits": 2,
|
|
38
39
|
"auto_update": False,
|
|
40
|
+
"account_id": "",
|
|
41
|
+
"last_auth_at": 0,
|
|
39
42
|
"publish_redact": {
|
|
40
43
|
"command_output": True,
|
|
41
44
|
"ai_prompts": True,
|
|
@@ -91,15 +91,27 @@ def install() -> str | None:
|
|
|
91
91
|
hooks[event] = [{"matcher": "", "hooks": [entry]}]
|
|
92
92
|
changed = True
|
|
93
93
|
else:
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
94
|
+
# Remove any stale methodproof hooks (different install path)
|
|
95
|
+
cleaned = []
|
|
96
|
+
has_current = False
|
|
97
|
+
for group in hooks[event]:
|
|
98
|
+
group_hooks = []
|
|
99
|
+
for h in group.get("hooks", []):
|
|
100
|
+
cmd = h.get("command", "")
|
|
101
|
+
if "methodproof" in cmd:
|
|
102
|
+
if cmd == script:
|
|
103
|
+
has_current = True
|
|
104
|
+
group_hooks.append(h)
|
|
105
|
+
else:
|
|
106
|
+
changed = True # dropping stale entry
|
|
107
|
+
else:
|
|
108
|
+
group_hooks.append(h)
|
|
109
|
+
if group_hooks:
|
|
110
|
+
cleaned.append({**group, "hooks": group_hooks})
|
|
111
|
+
if not has_current:
|
|
112
|
+
cleaned.append({"matcher": "", "hooks": [entry]})
|
|
102
113
|
changed = True
|
|
114
|
+
hooks[event] = cleaned
|
|
103
115
|
|
|
104
116
|
if changed:
|
|
105
117
|
settings["hooks"] = hooks
|
|
@@ -6,12 +6,15 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def compute_event_hash(event: dict[str, Any], prev_hash: str) -> str:
|
|
10
|
-
"""SHA-256
|
|
9
|
+
def compute_event_hash(event: dict[str, Any], prev_hash: str, account_id: str = "") -> str:
|
|
10
|
+
"""SHA-256 chain link. Includes account_id when present (new format), omits for legacy."""
|
|
11
11
|
metadata_hash = hashlib.sha256(
|
|
12
12
|
json.dumps(event.get("metadata", {}), sort_keys=True, default=str).encode()
|
|
13
13
|
).hexdigest()
|
|
14
|
-
|
|
14
|
+
if account_id:
|
|
15
|
+
payload = f"{event['id']}:{account_id}:{event['type']}:{event['timestamp']}:{metadata_hash}:{prev_hash}"
|
|
16
|
+
else:
|
|
17
|
+
payload = f"{event['id']}:{event['type']}:{event['timestamp']}:{metadata_hash}:{prev_hash}"
|
|
15
18
|
return hashlib.sha256(payload.encode()).hexdigest()
|
|
16
19
|
|
|
17
20
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Key derivation from master secret — HKDF-SHA256 with versioned info strings."""
|
|
2
|
+
|
|
3
|
+
from cryptography.hazmat.primitives.hashes import SHA256
|
|
4
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def derive_master(entropy: bytes) -> bytes:
|
|
8
|
+
"""128-bit entropy → 256-bit master secret."""
|
|
9
|
+
return HKDF(algorithm=SHA256(), length=32, salt=None, info=b"master-v1").derive(entropy)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def derive_db_key(master: bytes, account_id: str) -> bytes:
|
|
13
|
+
"""Master secret → AES-256 key for local DB field encryption."""
|
|
14
|
+
return HKDF(
|
|
15
|
+
algorithm=SHA256(), length=32,
|
|
16
|
+
salt=account_id.encode(), info=b"local-db-v1",
|
|
17
|
+
).derive(master)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def derive_bind_key(master: bytes, account_id: str) -> bytes:
|
|
21
|
+
"""Master secret → HMAC key for session binding signatures."""
|
|
22
|
+
return HKDF(
|
|
23
|
+
algorithm=SHA256(), length=32,
|
|
24
|
+
salt=account_id.encode(), info=b"session-bind-v1",
|
|
25
|
+
).derive(master)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""OS keychain storage for master secret — macOS Keychain, Linux secret-service, Windows Credential Manager."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_SERVICE = "methodproof"
|
|
7
|
+
_FALLBACK_WARNING = (
|
|
8
|
+
" No OS keychain available. Master secret stored as file (owner-only permissions).\n"
|
|
9
|
+
" For better security, install a keyring backend (e.g., gnome-keyring).\n"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _fallback_path() -> Path:
|
|
14
|
+
from methodproof import config
|
|
15
|
+
return config.DIR / "master.key"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def store_secret(account_id: str, secret: bytes) -> None:
|
|
19
|
+
"""Store master secret in OS keychain, or fall back to file."""
|
|
20
|
+
try:
|
|
21
|
+
import keyring
|
|
22
|
+
keyring.set_password(_SERVICE, account_id, secret.hex())
|
|
23
|
+
except Exception:
|
|
24
|
+
_store_file(secret)
|
|
25
|
+
sys.stderr.write(_FALLBACK_WARNING)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_secret(account_id: str) -> bytes | None:
|
|
29
|
+
"""Load master secret from OS keychain or fallback file."""
|
|
30
|
+
try:
|
|
31
|
+
import keyring
|
|
32
|
+
val = keyring.get_password(_SERVICE, account_id)
|
|
33
|
+
if val:
|
|
34
|
+
return bytes.fromhex(val)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
return _load_file()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def delete_secret(account_id: str) -> None:
|
|
41
|
+
"""Remove master secret from keychain and fallback file."""
|
|
42
|
+
try:
|
|
43
|
+
import keyring
|
|
44
|
+
keyring.delete_password(_SERVICE, account_id)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
path = _fallback_path()
|
|
48
|
+
if path.exists():
|
|
49
|
+
path.unlink()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def has_secret(account_id: str) -> bool:
|
|
53
|
+
return load_secret(account_id) is not None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _store_file(secret: bytes) -> None:
|
|
57
|
+
from methodproof.config import ensure_dirs, secure_file
|
|
58
|
+
ensure_dirs()
|
|
59
|
+
path = _fallback_path()
|
|
60
|
+
path.write_text(secret.hex())
|
|
61
|
+
secure_file(path)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_file() -> bytes | None:
|
|
65
|
+
path = _fallback_path()
|
|
66
|
+
if not path.exists():
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
return bytes.fromhex(path.read_text().strip())
|
|
70
|
+
except (ValueError, OSError):
|
|
71
|
+
return None
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Lock/purge — destroy local key access, optionally delete all data."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from methodproof import config, store
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def lock(account_id: str, purge: bool = False) -> None:
|
|
9
|
+
"""Destroy master secret from keychain. Optionally delete DB entirely."""
|
|
10
|
+
from methodproof.keychain import delete_secret
|
|
11
|
+
delete_secret(account_id)
|
|
12
|
+
|
|
13
|
+
if purge:
|
|
14
|
+
import shutil
|
|
15
|
+
if config.DB_PATH.exists():
|
|
16
|
+
config.DB_PATH.unlink()
|
|
17
|
+
# Clean up WAL/SHM files
|
|
18
|
+
for suffix in (".db-wal", ".db-shm"):
|
|
19
|
+
p = config.DB_PATH.with_suffix(suffix)
|
|
20
|
+
if p.exists():
|
|
21
|
+
p.unlink()
|
|
22
|
+
print(" Database deleted.")
|
|
23
|
+
else:
|
|
24
|
+
print(" Master key destroyed. Encrypted fields are now inaccessible.")
|
|
25
|
+
print(" Structural metadata (types, timestamps, paths) remains.")
|
|
26
|
+
print(" Restore with: mp login (enter recovery phrase)")
|
|
27
|
+
|
|
28
|
+
# Notify platform (best-effort)
|
|
29
|
+
cfg = config.load()
|
|
30
|
+
if cfg.get("token"):
|
|
31
|
+
try:
|
|
32
|
+
from methodproof.sync import _request
|
|
33
|
+
from methodproof.binding import compute_device_id
|
|
34
|
+
_request("POST", "/personal/lock-event", cfg["api_url"], cfg["token"],
|
|
35
|
+
{"device_id": compute_device_id(), "purged": purge})
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
# Clear master key fingerprint from config
|
|
40
|
+
cfg["master_key_fingerprint"] = ""
|
|
41
|
+
config.save(cfg)
|