granola-cli 0.1.0__py3-none-any.whl

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.
granola/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """Granola — decrypt on-disk credentials, auto-refresh, and drive the documented internal API.
2
+
3
+ Quick start::
4
+
5
+ from granola import GranolaClient, notes, sharing
6
+ client = GranolaClient()
7
+ me = client.invoke("get-user-info")
8
+ recent = notes.list_notes(client, limit=10)
9
+ sharing.add_collaborator(client, "<doc-id>", "person@example.com", name="Person")
10
+
11
+ Headless / portable auth::
12
+
13
+ from granola import GranolaClient, SessionFileSource, Config
14
+ cfg = Config()
15
+ client = GranolaClient(cfg, source=SessionFileSource(cfg, "session.json"))
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from . import editing, notes, sharing
20
+ from .auth import (
21
+ RefreshRevoked,
22
+ format_token_status,
23
+ refresh_exchange,
24
+ token_is_expiring,
25
+ )
26
+ from .client import GranolaClient
27
+ from .config import Config
28
+ from .routes import load_routes, resolve_endpoint
29
+ from .sources import (
30
+ DesktopStoreSource,
31
+ SessionFileSource,
32
+ StaticTokenSource,
33
+ TokenSource,
34
+ create_session_file,
35
+ resolve_source,
36
+ )
37
+ from .store import get_dek, read_store, save_store
38
+
39
+ __version__ = "0.1.0"
40
+ __all__ = [
41
+ "Config",
42
+ "GranolaClient",
43
+ "TokenSource",
44
+ "DesktopStoreSource",
45
+ "SessionFileSource",
46
+ "StaticTokenSource",
47
+ "resolve_source",
48
+ "create_session_file",
49
+ "refresh_exchange",
50
+ "format_token_status",
51
+ "token_is_expiring",
52
+ "RefreshRevoked",
53
+ "load_routes",
54
+ "resolve_endpoint",
55
+ "read_store",
56
+ "save_store",
57
+ "get_dek",
58
+ "notes",
59
+ "sharing",
60
+ "editing",
61
+ ]
granola/_http.py ADDED
@@ -0,0 +1,49 @@
1
+ """HTTP helpers: base headers + redirect-safe request via httpx."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ import sys
6
+
7
+ import httpx
8
+
9
+
10
+ def base_headers(cfg, access_token: str | None = None, additional: dict | None = None) -> dict:
11
+ headers = {
12
+ "X-Client-Version": cfg.client_version,
13
+ "X-Granola-Platform": cfg.platform,
14
+ "Accept": "application/json",
15
+ "User-Agent": f"Granola/{cfg.client_version}",
16
+ }
17
+ if access_token:
18
+ headers["Authorization"] = f"Bearer {access_token}"
19
+ if additional:
20
+ headers.update(additional)
21
+ return headers
22
+
23
+
24
+ def request(method: str, url: str, *, json_body=None, headers: dict | None = None,
25
+ timeout: float = 60.0) -> httpx.Response:
26
+ # follow_redirects=True keeps the POST body across 307/308 (httpx, unlike the
27
+ # old PowerShell Invoke-RestMethod, does this correctly).
28
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
29
+ return client.request(method.upper(), url, json=json_body, headers=headers)
30
+
31
+
32
+ def granola_running() -> bool:
33
+ """Best-effort check whether the desktop app is running (Windows/macOS)."""
34
+ try:
35
+ if sys.platform == "win32":
36
+ out = subprocess.run(
37
+ ["tasklist", "/FI", "IMAGENAME eq Granola.exe", "/NH"],
38
+ capture_output=True, text=True, timeout=5,
39
+ )
40
+ return "Granola.exe" in out.stdout
41
+ if sys.platform == "darwin":
42
+ out = subprocess.run(
43
+ ["/usr/bin/pgrep", "-x", "Granola"],
44
+ capture_output=True, text=True, timeout=5,
45
+ )
46
+ return out.returncode == 0 and bool(out.stdout.strip())
47
+ except Exception:
48
+ return False
49
+ return False
granola/auth.py ADDED
@@ -0,0 +1,103 @@
1
+ """Token primitives: the refresh HTTP exchange, expiry math, account selection,
2
+ and status formatting.
3
+
4
+ These are deliberately persistence-free. *Where* a refreshed token gets written
5
+ back (the encrypted desktop store vs. a portable session file) lives in
6
+ ``sources.py`` — this module only knows how to talk to the refresh endpoint and
7
+ how to reason about a token dict.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from datetime import datetime, timezone
13
+
14
+ from ._http import base_headers, request
15
+ from .config import Config
16
+
17
+
18
+ class RefreshRevoked(RuntimeError):
19
+ """The refresh token was rejected (revoked or already rotated away).
20
+
21
+ Carries a source-specific, human-readable recovery message — the desktop and
22
+ session sources re-raise it with the right re-auth instructions.
23
+ """
24
+
25
+
26
+ def _now_ms() -> int:
27
+ return int(time.time() * 1000)
28
+
29
+
30
+ def token_is_expiring(token: dict, skew_ms: int) -> bool:
31
+ expiry_ms = int(token["obtained_at"]) + int(token["expires_in"]) * 1000
32
+ return (expiry_ms - _now_ms()) < skew_ms
33
+
34
+
35
+ def select_account(store, email: str | None = None):
36
+ if email:
37
+ for acct in store.accounts:
38
+ if acct.get("email") == email:
39
+ return acct
40
+ raise ValueError(f"No stored account with email '{email}'.")
41
+ return store.accounts[0]
42
+
43
+
44
+ def refresh_exchange(cfg: Config, tok: dict) -> dict:
45
+ """POST the refresh token and return a *new* token dict. Pure — no write-back.
46
+
47
+ Raises ``RefreshRevoked`` on 401 (revoked/rotated) and ``RuntimeError`` on any
48
+ other non-2xx. The returned dict is a copy of ``tok`` with the rotated fields
49
+ applied, so callers decide where to persist it.
50
+ """
51
+ if not tok.get("refresh_token"):
52
+ raise ValueError("No refresh_token available to refresh.")
53
+
54
+ headers = base_headers(cfg, tok["access_token"])
55
+ resp = request("POST", cfg.refresh_url,
56
+ json_body={"refresh_token": tok["refresh_token"]},
57
+ headers=headers, timeout=cfg.timeout)
58
+
59
+ if resp.status_code == 401:
60
+ kind = None
61
+ try:
62
+ kind = resp.json().get("error")
63
+ except Exception:
64
+ pass
65
+ raise RefreshRevoked(f"Refresh rejected (401{': ' + kind if kind else ''}).")
66
+ if resp.status_code >= 400:
67
+ raise RuntimeError(f"Refresh failed: HTTP {resp.status_code}. {resp.text[:300]}")
68
+
69
+ data = resp.json()
70
+ if not data.get("access_token"):
71
+ raise RuntimeError("Refresh OK but no access_token in response.")
72
+
73
+ new = dict(tok)
74
+ new["access_token"] = data["access_token"]
75
+ new["expires_in"] = data.get("expires_in", tok.get("expires_in"))
76
+ for key in ("refresh_token", "token_type", "session_id", "sign_in_method"):
77
+ if data.get(key):
78
+ new[key] = data[key]
79
+ new["obtained_at"] = _now_ms()
80
+ return new
81
+
82
+
83
+ def format_token_status(tok: dict, skew_ms: int, include_secrets: bool = False) -> dict:
84
+ """A no-secrets status view of one token dict (secrets gated behind the flag)."""
85
+ obt_ms = int(tok["obtained_at"])
86
+ obtained = datetime.fromtimestamp(obt_ms / 1000, tz=timezone.utc)
87
+ expiry = datetime.fromtimestamp(
88
+ (obt_ms + int(tok["expires_in"]) * 1000) / 1000, tz=timezone.utc
89
+ )
90
+ now = datetime.now(timezone.utc)
91
+ info = {
92
+ "token_type": tok.get("token_type"),
93
+ "sign_in_method": tok.get("sign_in_method"),
94
+ "obtained_at_utc": obtained.isoformat(),
95
+ "expiry_utc": expiry.isoformat(),
96
+ "expired": now > expiry,
97
+ "expiring_soon": token_is_expiring(tok, skew_ms),
98
+ "minutes_left": round((expiry - now).total_seconds() / 60, 1),
99
+ }
100
+ if include_secrets:
101
+ info["access_token"] = tok.get("access_token")
102
+ info["refresh_token"] = tok.get("refresh_token")
103
+ return info
granola/cli.py ADDED
@@ -0,0 +1,237 @@
1
+ """`granola` — one CLI for the documented Granola API: credentials + notes + sharing + editing.
2
+
3
+ Auth: auth status | auth token | auth refresh | auth export
4
+ Engine: routes | call
5
+ Read: notes | get | meta | transcript | panels
6
+ Share: who | share | unshare | role | share-folder | folder-who | unshare-folder
7
+ Edit: update | delete
8
+
9
+ Global auth options (before the command) select the token source:
10
+ --email EMAIL pick an account from the local desktop credentials
11
+ --session PATH use a refreshable session file
12
+ --access-token TOK use this bearer token directly (no refresh)
13
+ --no-refresh never auto-refresh
14
+ Environment: GRANOLA_SESSION, GRANOLA_ACCESS_TOKEN.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from . import editing, notes, sharing
24
+ from .client import GranolaClient
25
+ from .config import Config
26
+ from .routes import load_routes
27
+ from .sources import create_session_file, resolve_source
28
+
29
+ DEFAULT_SESSION_PATH = Path.home() / ".config" / "granola" / "session.json"
30
+
31
+
32
+ def _print(obj) -> None:
33
+ print(obj if isinstance(obj, str) else json.dumps(obj, indent=2, ensure_ascii=False))
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ p = argparse.ArgumentParser(prog="granola", description=__doc__,
38
+ formatter_class=argparse.RawDescriptionHelpFormatter)
39
+ p.add_argument("--email", help="Select a specific stored desktop account.")
40
+ p.add_argument("--session", help="Use a refreshable session JSON file.")
41
+ p.add_argument("--access-token", help="Use this bearer token directly (no refresh).")
42
+ p.add_argument("--no-refresh", action="store_true", help="Never auto-refresh the token.")
43
+ sub = p.add_subparsers(dest="cmd", required=True)
44
+
45
+ # --- auth ---
46
+ pa = sub.add_parser("auth", help="Token / session management.")
47
+ asub = pa.add_subparsers(dest="auth_cmd", required=True)
48
+ pas = asub.add_parser("status", help="Token/account status (no secrets by default).")
49
+ pas.add_argument("--include-secrets", action="store_true")
50
+ asub.add_parser("token", help="Print the current valid bearer token.")
51
+ asub.add_parser("refresh", help="Force-refresh the selected source.")
52
+ pae = asub.add_parser("export", help="Write a refreshable session file from desktop creds.")
53
+ pae.add_argument("path", nargs="?", default=None,
54
+ help=f"Destination (default: {DEFAULT_SESSION_PATH}).")
55
+ pae.add_argument("--no-refresh-token", action="store_true",
56
+ help="Bearer-only session (cannot refresh; for short-lived/CI use).")
57
+
58
+ # --- engine ---
59
+ pr = sub.add_parser("routes", help="List endpoint routes (optional filter).")
60
+ pr.add_argument("filter", nargs="?", default="")
61
+ pc = sub.add_parser("call", help="Call any endpoint by name or URL.")
62
+ pc.add_argument("endpoint")
63
+ pc.add_argument("--body", help='JSON body, e.g. \'{"limit":5}\'.')
64
+ pc.add_argument("--method", default="POST")
65
+ pc.add_argument("--raw", action="store_true")
66
+
67
+ # --- read ---
68
+ pn = sub.add_parser("notes", help="List recent notes.")
69
+ pn.add_argument("--limit", type=int, default=20)
70
+ pn.add_argument("--json", action="store_true")
71
+ pg = sub.add_parser("get", help="Full record for one note.")
72
+ pg.add_argument("id")
73
+ pg.add_argument("--json", action="store_true")
74
+ pm = sub.add_parser("meta", help="Creator/attendees/conferencing for a note.")
75
+ pm.add_argument("id")
76
+ pt = sub.add_parser("transcript", help="Transcript as markdown.")
77
+ pt.add_argument("id")
78
+ pp = sub.add_parser("panels", help="AI summary panels for a note.")
79
+ pp.add_argument("id")
80
+ pp.add_argument("--json", action="store_true")
81
+
82
+ # --- share ---
83
+ pw = sub.add_parser("who", help="Who has access to a note.")
84
+ pw.add_argument("id")
85
+ ps = sub.add_parser("share", help="Add a collaborator to a note.")
86
+ ps.add_argument("id")
87
+ ps.add_argument("--email", required=True)
88
+ ps.add_argument("--name")
89
+ ps.add_argument("--role", default="collaborator")
90
+ pu = sub.add_parser("unshare", help="Remove a collaborator from a note.")
91
+ pu.add_argument("id")
92
+ pu.add_argument("--email", required=True)
93
+ pu.add_argument("--cleanup-list", action="append", default=[],
94
+ help="Also strip inherited access from this folder id (repeatable).")
95
+ prole = sub.add_parser("role", help="Change a collaborator's role.")
96
+ prole.add_argument("id")
97
+ prole.add_argument("--user", required=True, help="user_id (from `who`).")
98
+ prole.add_argument("--role", required=True)
99
+ pf = sub.add_parser("share-folder", help="Share a folder with someone (folder-level access).")
100
+ pf.add_argument("folder", help='Folder id or name (e.g. "Team Notes").')
101
+ pf.add_argument("--email", required=True)
102
+ pf.add_argument("--name")
103
+ pf.add_argument("--role", default="collaborator")
104
+ pf.add_argument("--per-note", action="store_true",
105
+ help="Add to each note directly (invites non-Granola emails; vs one-call folder ACL).")
106
+ pf.add_argument("--include-existing", action="store_true",
107
+ help="(per-note) Re-add even where the person already has access.")
108
+ pfw = sub.add_parser("folder-who", help="Who has access to a folder.")
109
+ pfw.add_argument("folder")
110
+ puf = sub.add_parser("unshare-folder", help="Revoke a person's folder-level access.")
111
+ puf.add_argument("folder")
112
+ puf.add_argument("--email", required=True)
113
+
114
+ # --- edit ---
115
+ pup = sub.add_parser("update", help="Partial-edit a note (title/markdown).")
116
+ pup.add_argument("id")
117
+ pup.add_argument("--title")
118
+ pup.add_argument("--markdown")
119
+ pd = sub.add_parser("delete", help="PERMANENTLY hard-delete a note.")
120
+ pd.add_argument("id")
121
+ pd.add_argument("--yes", action="store_true", help="Required: confirm.")
122
+ return p
123
+
124
+
125
+ def main(argv=None) -> int: # noqa: C901 - flat dispatch is clearer than abstraction here
126
+ try: # Windows console defaults to cp1252; note content is UTF-8 (emoji, smart quotes)
127
+ sys.stdout.reconfigure(encoding="utf-8")
128
+ except Exception:
129
+ pass
130
+ args = build_parser().parse_args(argv)
131
+ cfg = Config()
132
+ c = args.cmd
133
+
134
+ # auth export reads the desktop store directly; it doesn't use the resolved source.
135
+ if c == "auth" and args.auth_cmd == "export":
136
+ if args.session or args.access_token:
137
+ print("auth export reads the desktop credential store; "
138
+ "don't pass --session/--access-token.", file=sys.stderr)
139
+ return 2
140
+ dest = args.path or str(DEFAULT_SESSION_PATH)
141
+ out = create_session_file(cfg, dest, email=args.email,
142
+ include_refresh_token=not args.no_refresh_token)
143
+ mode = "owner-only" if sys.platform == "win32" else "0600"
144
+ kind = "bearer-only" if args.no_refresh_token else "refreshable"
145
+ print(f"Wrote {kind} session file: {out} (mode {mode})", file=sys.stderr)
146
+ print(out)
147
+ return 0
148
+
149
+ source = resolve_source(cfg, email=args.email, session=args.session,
150
+ access_token=args.access_token, no_refresh=args.no_refresh)
151
+ client = GranolaClient(cfg, source=source)
152
+
153
+ # auth
154
+ if c == "auth":
155
+ if args.auth_cmd == "status":
156
+ _print(source.status(include_secrets=args.include_secrets))
157
+ elif args.auth_cmd == "token":
158
+ print(source.access_token())
159
+ elif args.auth_cmd == "refresh":
160
+ source.access_token(force=True)
161
+ _print(source.status(include_secrets=False))
162
+
163
+ # engine
164
+ elif c == "routes":
165
+ for name in sorted(load_routes(cfg)):
166
+ if args.filter in name:
167
+ print(f"{name:40} {load_routes(cfg)[name]}")
168
+ elif c == "call":
169
+ body = json.loads(args.body) if args.body else None
170
+ _print(client.invoke(args.endpoint, body=body, method=args.method, raw=args.raw))
171
+
172
+ # read
173
+ elif c == "notes":
174
+ docs = notes.list_notes(client, limit=args.limit)
175
+ if args.json:
176
+ _print(docs)
177
+ else:
178
+ for d in docs:
179
+ print(f"{d.get('id')} {d.get('updated_at','')[:19]:19} {d.get('title') or '(untitled)'}")
180
+ print(f"{len(docs)} notes", file=sys.stderr)
181
+ elif c == "get":
182
+ rec = notes.get_note(client, args.id)
183
+ if not rec:
184
+ print("note not found", file=sys.stderr)
185
+ return 1
186
+ if args.json:
187
+ _print(rec)
188
+ else:
189
+ for k in ("id", "title", "created_at", "updated_at", "workspace_id",
190
+ "public", "visibility", "document_user_role", "is_shared_direct"):
191
+ print(f"{k:20} {rec.get(k)}")
192
+ elif c == "meta":
193
+ _print(notes.get_metadata(client, args.id))
194
+ elif c == "transcript":
195
+ print(notes.transcript_to_markdown(notes.get_transcript(client, args.id)))
196
+ elif c == "panels":
197
+ _print(notes.get_panels(client, args.id))
198
+
199
+ # share
200
+ elif c == "who":
201
+ for u in sharing.list_collaborators(client, args.id):
202
+ print(f"{u.get('role',''):13} {u.get('email',''):35} {u.get('user_id','')}")
203
+ elif c == "share":
204
+ _print(sharing.add_collaborator(client, args.id, args.email, name=args.name, role=args.role))
205
+ elif c == "unshare":
206
+ _print(sharing.remove_collaborator(client, args.id, args.email,
207
+ cleanup_list_ids=args.cleanup_list or None))
208
+ elif c == "role":
209
+ _print(sharing.set_role(client, args.id, args.user, args.role))
210
+ elif c == "share-folder":
211
+ _print(sharing.share_folder(client, args.folder, args.email, name=args.name,
212
+ role=args.role, per_note=args.per_note,
213
+ skip_existing=not args.include_existing))
214
+ elif c == "folder-who":
215
+ for u in sharing.list_folder_collaborators(client, args.folder):
216
+ print(f"{u.get('role',''):13} {u.get('email',''):35} {u.get('access_source','')}")
217
+ elif c == "unshare-folder":
218
+ _print(sharing.unshare_folder(client, args.folder, args.email))
219
+
220
+ # edit
221
+ elif c == "update":
222
+ fields = {k: v for k, v in (("title", args.title), ("notes_markdown", args.markdown)) if v is not None}
223
+ if not fields:
224
+ print("nothing to update (pass --title and/or --markdown)", file=sys.stderr)
225
+ return 2
226
+ _print(editing.update_note(client, args.id, **fields))
227
+ elif c == "delete":
228
+ if not args.yes:
229
+ print("refusing to hard-delete without --yes (this is permanent)", file=sys.stderr)
230
+ return 2
231
+ _print(editing.delete_note(client, args.id))
232
+
233
+ return 0
234
+
235
+
236
+ if __name__ == "__main__":
237
+ sys.exit(main())
granola/client.py ADDED
@@ -0,0 +1,46 @@
1
+ """High-level Granola API client."""
2
+ from __future__ import annotations
3
+
4
+ from ._http import base_headers, request
5
+ from .config import Config
6
+ from .routes import resolve_endpoint
7
+ from .sources import DesktopStoreSource, TokenSource
8
+
9
+
10
+ class GranolaClient:
11
+ """Call Granola API endpoints with a bearer token from a ``TokenSource``.
12
+
13
+ The source (desktop store, session file, or static token) is resolved once and
14
+ handles its own refresh + write-back, so call sites stay auth-agnostic.
15
+
16
+ >>> c = GranolaClient() # defaults to the desktop store
17
+ >>> c.invoke("get-user-info")
18
+ >>> c.invoke("get-documents", body={"limit": 10})
19
+ """
20
+
21
+ def __init__(self, cfg: Config | None = None, source: TokenSource | None = None):
22
+ self.cfg = cfg or Config()
23
+ self.source = source or DesktopStoreSource(self.cfg)
24
+
25
+ def access_token(self, force: bool = False) -> str:
26
+ return self.source.access_token(force=force)
27
+
28
+ def resolve(self, endpoint: str) -> str:
29
+ return resolve_endpoint(endpoint, self.cfg)
30
+
31
+ def invoke(self, endpoint: str, body=None, method: str = "POST",
32
+ additional_headers: dict | None = None, raw: bool = False):
33
+ token = self.source.access_token()
34
+ url = self.resolve(endpoint)
35
+ headers = base_headers(self.cfg, token, additional_headers)
36
+ resp = request(method, url, json_body=body, headers=headers, timeout=self.cfg.timeout)
37
+ if resp.status_code >= 400:
38
+ raise RuntimeError(
39
+ f"Granola API '{endpoint}' returned HTTP {resp.status_code}. {resp.text[:500]}"
40
+ )
41
+ if raw:
42
+ return resp.text
43
+ try:
44
+ return resp.json()
45
+ except Exception:
46
+ return resp.text
granola/config.py ADDED
@@ -0,0 +1,48 @@
1
+ """Configuration for the Granola API engine."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ def _default_granola_dir() -> Path:
11
+ if sys.platform == "darwin":
12
+ return Path.home() / "Library" / "Application Support" / "Granola"
13
+ appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
14
+ return Path(appdata) / "Granola"
15
+
16
+
17
+ def _default_app_root() -> Path:
18
+ if sys.platform == "darwin":
19
+ return Path("/Applications/Granola.app/Contents")
20
+ local = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
21
+ return Path(local) / "Programs" / "@granolaelectron"
22
+
23
+
24
+ @dataclass
25
+ class Config:
26
+ """Resolves paths and constants for one Granola profile.
27
+
28
+ For a *headless second session* (Option B), point ``granola_dir`` at the
29
+ second OS user's ``%APPDATA%\\Granola`` (the process must run as that user so
30
+ DPAPI CurrentUser can unseal the key).
31
+ """
32
+
33
+ granola_dir: Path = field(default_factory=_default_granola_dir)
34
+ app_root: Path = field(default_factory=_default_app_root)
35
+ client_version: str = "7.303.0" # app.asar package.json version
36
+ # X-Granola-Platform (darwin -> macOS, win32 -> Windows)
37
+ platform: str = field(
38
+ default_factory=lambda: "macOS" if sys.platform == "darwin" else "Windows")
39
+ refresh_url: str = "https://api.granola.ai/v1/refresh-access-token"
40
+ refresh_skew_ms: int = 120_000 # app refreshes when <2 min to expiry
41
+ timeout: float = 60.0
42
+ routes_path: Path | None = None
43
+
44
+ def __post_init__(self) -> None:
45
+ self.granola_dir = Path(self.granola_dir)
46
+ self.app_root = Path(self.app_root)
47
+ if self.routes_path is not None:
48
+ self.routes_path = Path(self.routes_path)
granola/crypto.py ADDED
@@ -0,0 +1,117 @@
1
+ """Windows DPAPI unseal + AES-256-GCM (clean, via the `cryptography` lib).
2
+
3
+ DPAPI uses a tiny ctypes shim against crypt32.dll so the only third-party
4
+ dependency is `cryptography` (no pywin32). DPAPI is inherently Windows + same
5
+ Windows user (CurrentUser scope); everything above the access token is portable.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ctypes
10
+ import hashlib
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from ctypes import wintypes
15
+
16
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
17
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
18
+
19
+
20
+ def dpapi_unprotect(blob: bytes) -> bytes:
21
+ """Decrypt a Windows DPAPI blob under the CurrentUser scope."""
22
+ if sys.platform != "win32":
23
+ raise RuntimeError("DPAPI decryption is only available on Windows.")
24
+
25
+ class DATA_BLOB(ctypes.Structure):
26
+ _fields_ = [("cbData", wintypes.DWORD),
27
+ ("pbData", ctypes.POINTER(ctypes.c_byte))]
28
+
29
+ buf_in = ctypes.create_string_buffer(bytes(blob), len(blob))
30
+ blob_in = DATA_BLOB(len(blob), ctypes.cast(buf_in, ctypes.POINTER(ctypes.c_byte)))
31
+ blob_out = DATA_BLOB()
32
+
33
+ crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
34
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
35
+ ok = crypt32.CryptUnprotectData(
36
+ ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out)
37
+ )
38
+ if not ok:
39
+ raise ctypes.WinError(ctypes.get_last_error())
40
+ try:
41
+ return ctypes.string_at(blob_out.pbData, blob_out.cbData)
42
+ finally:
43
+ kernel32.LocalFree(blob_out.pbData)
44
+
45
+
46
+ def aes_gcm_decrypt(blob: bytes, key: bytes, prefix_len: int = 0, iv_len: int = 12) -> bytes:
47
+ """Decrypt a `[prefix?] + IV(12) + ciphertext + tag(16)` blob.
48
+
49
+ `cryptography` expects the GCM tag appended to the ciphertext, which is
50
+ exactly Granola's on-disk layout after the IV.
51
+ """
52
+ nonce = blob[prefix_len:prefix_len + iv_len]
53
+ ct_and_tag = blob[prefix_len + iv_len:]
54
+ return AESGCM(key).decrypt(nonce, ct_and_tag, None)
55
+
56
+
57
+ def aes_gcm_encrypt(plaintext: bytes, key: bytes, iv_len: int = 12) -> bytes:
58
+ """Produce `IV(12) + ciphertext + tag(16)`, matching Granola's `.enc` layout."""
59
+ nonce = os.urandom(iv_len)
60
+ return nonce + AESGCM(key).encrypt(nonce, plaintext, None)
61
+
62
+
63
+ # --- macOS safeStorage (Keychain-backed) ---------------------------------------
64
+ # On macOS, Granola's `storage.dek` is wrapped with Electron/Chromium safeStorage
65
+ # (AES-128-CBC, key derived from a Keychain password) instead of Windows DPAPI.
66
+ # Scheme + behavior verified against harperreed/muesli's session_decrypt.rs.
67
+
68
+ KEYCHAIN_SERVICE = "Granola Safe Storage"
69
+ KEYCHAIN_ACCOUNT = "Granola Key"
70
+ _SAFE_STORAGE_SALT = b"saltysalt"
71
+ _SAFE_STORAGE_ITERATIONS = 1003
72
+ _SAFE_STORAGE_KEY_LEN = 16
73
+ _SAFE_STORAGE_IV = b" " * 16
74
+
75
+
76
+ def keychain_password(service: str = KEYCHAIN_SERVICE, account: str = KEYCHAIN_ACCOUNT) -> str:
77
+ """Read Granola's safeStorage key from the macOS login Keychain.
78
+
79
+ Shells out to ``/usr/bin/security`` (no extra deps). The first call may trigger
80
+ a Keychain access prompt unless this binary is already trusted for the item.
81
+ """
82
+ if sys.platform != "darwin":
83
+ raise RuntimeError("Keychain access is only available on macOS.")
84
+ try:
85
+ out = subprocess.run(
86
+ ["/usr/bin/security", "find-generic-password", "-s", service, "-a", account, "-w"],
87
+ capture_output=True, text=True, timeout=20,
88
+ )
89
+ except FileNotFoundError as exc:
90
+ raise RuntimeError("`security` tool not found (macOS only).") from exc
91
+ if out.returncode != 0:
92
+ raise RuntimeError(
93
+ f"Keychain item '{service}'/'{account}' not found or access denied: "
94
+ f"{out.stderr.strip() or out.returncode}"
95
+ )
96
+ return out.stdout.strip("\n")
97
+
98
+
99
+ def aes_128_cbc_safestorage_decrypt(blob: bytes, password: str, prefix: bytes = b"v10") -> bytes:
100
+ """Decrypt an Electron/Chromium safeStorage ``v10`` blob (macOS/Linux scheme).
101
+
102
+ Key = PBKDF2-HMAC-SHA1(password, "saltysalt", 1003, 16 bytes); AES-128-CBC with a
103
+ 16-space IV and PKCS#7 padding. Used for ``storage.dek`` on macOS.
104
+ """
105
+ if blob[: len(prefix)] != prefix:
106
+ raise ValueError(f"safeStorage blob missing {prefix!r} prefix.")
107
+ ciphertext = blob[len(prefix):]
108
+ key = hashlib.pbkdf2_hmac(
109
+ "sha1", password.encode("utf-8"), _SAFE_STORAGE_SALT,
110
+ _SAFE_STORAGE_ITERATIONS, dklen=_SAFE_STORAGE_KEY_LEN,
111
+ )
112
+ dec = Cipher(algorithms.AES(key), modes.CBC(_SAFE_STORAGE_IV)).decryptor()
113
+ padded = dec.update(ciphertext) + dec.finalize()
114
+ pad = padded[-1] if padded else 0
115
+ if not 1 <= pad <= 16 or pad > len(padded):
116
+ raise ValueError("Bad PKCS#7 padding (wrong Keychain password?).")
117
+ return padded[:-pad]