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.
Files changed (63) hide show
  1. {methodproof-0.4.4 → methodproof-0.5.0}/PKG-INFO +3 -5
  2. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/__init__.py +1 -1
  3. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/base.py +19 -5
  4. methodproof-0.5.0/methodproof/binding.py +28 -0
  5. methodproof-0.5.0/methodproof/bip39.py +37 -0
  6. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/cli.py +179 -4
  7. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/config.py +4 -1
  8. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/install.py +20 -8
  9. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/integrity.py +6 -3
  10. methodproof-0.5.0/methodproof/kdf.py +25 -0
  11. methodproof-0.5.0/methodproof/keychain.py +71 -0
  12. methodproof-0.5.0/methodproof/lock.py +41 -0
  13. methodproof-0.5.0/methodproof/migrate_db.py +42 -0
  14. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/store.py +17 -4
  15. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/sync.py +8 -2
  16. methodproof-0.5.0/methodproof/wordlist.py +2052 -0
  17. {methodproof-0.4.4 → methodproof-0.5.0}/pyproject.toml +2 -4
  18. methodproof-0.5.0/tests/test_security.py +370 -0
  19. {methodproof-0.4.4 → methodproof-0.5.0}/.github/workflows/ci.yml +0 -0
  20. {methodproof-0.4.4 → methodproof-0.5.0}/.gitignore +0 -0
  21. {methodproof-0.4.4 → methodproof-0.5.0}/CHANGELOG.md +0 -0
  22. {methodproof-0.4.4 → methodproof-0.5.0}/LICENSE +0 -0
  23. {methodproof-0.4.4 → methodproof-0.5.0}/README.md +0 -0
  24. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/__main__.py +0 -0
  25. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/__init__.py +0 -0
  26. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/music.py +0 -0
  27. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/terminal.py +0 -0
  28. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/agents/watcher.py +0 -0
  29. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/analysis.py +0 -0
  30. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/bridge.py +0 -0
  31. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/crypto.py +0 -0
  32. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/graph.py +0 -0
  33. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hook.py +0 -0
  34. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/__init__.py +0 -0
  35. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/claude_code.py +0 -0
  36. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/claude_code.sh +0 -0
  37. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/cline_hook.sh +0 -0
  38. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/codex_hook.sh +0 -0
  39. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/gemini_hook.sh +0 -0
  40. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/kiro_hook.sh +0 -0
  41. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/mcp_register.py +0 -0
  42. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/openclaw/HOOK.md +0 -0
  43. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/openclaw/handler.ts +0 -0
  44. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/openclaw_install.py +0 -0
  45. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/opencode_plugin.js +0 -0
  46. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/hooks/wrappers.py +0 -0
  47. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/live.py +0 -0
  48. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/mcp.py +0 -0
  49. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/proxy.py +0 -0
  50. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/proxy_daemon.py +0 -0
  51. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/repos.py +0 -0
  52. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/skills/methodproof/SKILL.md +0 -0
  53. {methodproof-0.4.4 → methodproof-0.5.0}/methodproof/viewer.py +0 -0
  54. {methodproof-0.4.4 → methodproof-0.5.0}/test_windows_compat.py +0 -0
  55. {methodproof-0.4.4 → methodproof-0.5.0}/tests/__init__.py +0 -0
  56. {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_analysis.py +0 -0
  57. {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_graph.py +0 -0
  58. {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_hooks.py +0 -0
  59. {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_live.py +0 -0
  60. {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_openclaw_hooks.py +0 -0
  61. {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_store.py +0 -0
  62. {methodproof-0.4.4 → methodproof-0.5.0}/tests/test_wrappers.py +0 -0
  63. {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.4.4
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">
@@ -1,3 +1,3 @@
1
1
  """MethodProof — see how you code."""
2
2
 
3
- __version__ = "0.4.4"
3
+ __version__ = "0.5.0"
@@ -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
- _journal_mode = False
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
- raw = cfg.get("e2e_key", "")
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 stream │")
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 stream │")
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
- store.create_session(sid, watch_dir, repo_url, json.dumps(tags), visibility)
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
- # Check if our hook is already installed
95
- existing_cmds = [
96
- h.get("command", "")
97
- for group in hooks[event]
98
- for h in group.get("hooks", [])
99
- ]
100
- if script not in existing_cmds:
101
- hooks[event].append({"matcher": "", "hooks": [entry]})
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 of {event_id}:{type}:{timestamp}:{metadata_hash}:{prev_hash}."""
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
- payload = f"{event['id']}:{event['type']}:{event['timestamp']}:{metadata_hash}:{prev_hash}"
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)