gargoyle-receipts 0.5.3.dev0__tar.gz → 0.6.0.dev0__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 (69) hide show
  1. {gargoyle_receipts-0.5.3.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.6.0.dev0}/PKG-INFO +10 -5
  2. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/README.md +9 -4
  3. gargoyle_receipts-0.6.0.dev0/gargoyle/approvers.py +116 -0
  4. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/cli.py +6 -1
  5. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/consumer.py +11 -1
  6. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/issuer_service.py +64 -2
  7. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/mcp_server.py +11 -0
  8. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/static/approve.html +52 -1
  9. gargoyle_receipts-0.6.0.dev0/gargoyle/static/approve.js +121 -0
  10. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +10 -5
  11. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
  12. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/pyproject.toml +1 -1
  13. gargoyle_receipts-0.6.0.dev0/tests/test_approvers.py +111 -0
  14. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_console_js.py +20 -0
  15. gargoyle_receipts-0.5.3.dev0/gargoyle/static/approve.js +0 -59
  16. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/LICENSE +0 -0
  17. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/NOTICE +0 -0
  18. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/__init__.py +0 -0
  19. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/approval.py +0 -0
  20. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/approver_cli.py +0 -0
  21. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/approver_trust.py +0 -0
  22. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/entra_login.py +0 -0
  23. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/envelope.py +0 -0
  24. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/evidence.py +0 -0
  25. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/execution.py +0 -0
  26. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/fs_server.py +0 -0
  27. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/identity.py +0 -0
  28. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/issuer.py +0 -0
  29. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/jws.py +0 -0
  30. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/key_record_source.py +0 -0
  31. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/notify.py +0 -0
  32. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/opa.py +0 -0
  33. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/pending_store.py +0 -0
  34. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/policy.py +0 -0
  35. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/published_record_source.py +0 -0
  36. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/replay_cache.py +0 -0
  37. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/rules.py +0 -0
  38. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/verifier.py +0 -0
  39. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  40. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  41. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  42. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  43. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/setup.cfg +0 -0
  44. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_annotations.py +0 -0
  45. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_approver_cli.py +0 -0
  46. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_canonical.py +0 -0
  47. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_cli.py +0 -0
  48. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_consumer.py +0 -0
  49. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_entra_identity.py +0 -0
  50. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_entra_login.py +0 -0
  51. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_evidence.py +0 -0
  52. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_execution.py +0 -0
  53. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_fs_server.py +0 -0
  54. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_historical.py +0 -0
  55. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_identity.py +0 -0
  56. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_issuer_service.py +0 -0
  57. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_jws.py +0 -0
  58. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_mcp_proxy.py +0 -0
  59. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  60. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_mcp_server.py +0 -0
  61. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_notify.py +0 -0
  62. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_opa.py +0 -0
  63. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_policy.py +0 -0
  64. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_published_record.py +0 -0
  65. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_rules.py +0 -0
  66. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_stdio_encoding.py +0 -0
  67. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_threshold.py +0 -0
  68. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_typescript_conformance.py +0 -0
  69. {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.5.3.dev0
3
+ Version: 0.6.0.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.5.3.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.6.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -255,9 +255,12 @@ Three commands, no policy engine, no containers, no client changes:
255
255
  gargoyle approve --watch 2 # where the approver sits (terminal)
256
256
 
257
257
  or approve in a browser: open the issuer's `/approve` page and load
258
- `approver-web.json`; the page shows each held call with its full arguments,
259
- checked against the receipt, and signs the grant locally with WebCrypto.
260
- The key never leaves the browser. Then paste the server entry from
258
+ `approver-web.json`, or, with Entra configured, sign in with Microsoft;
259
+ the page shows each held call with its full arguments, checked against
260
+ the receipt, and signs the grant locally with WebCrypto. The key never
261
+ leaves the browser, and with Entra it is generated there, non-exportable,
262
+ and enrolled under the approver's UPN, so grants and audits name the
263
+ person. Then paste the server entry from
261
264
  `gargoyle-deploy/mcp-config.json` into your MCP client.
262
265
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
263
266
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -298,6 +301,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
298
301
  | `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
299
302
  | issuer `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
300
303
  | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
304
+ | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
305
+ | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
301
306
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
302
307
  | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
303
308
 
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
21
21
 
22
22
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.5.3.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.6.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
25
25
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
26
26
 
27
27
  ## What a receipt gives you
@@ -217,9 +217,12 @@ Three commands, no policy engine, no containers, no client changes:
217
217
  gargoyle approve --watch 2 # where the approver sits (terminal)
218
218
 
219
219
  or approve in a browser: open the issuer's `/approve` page and load
220
- `approver-web.json`; the page shows each held call with its full arguments,
221
- checked against the receipt, and signs the grant locally with WebCrypto.
222
- The key never leaves the browser. Then paste the server entry from
220
+ `approver-web.json`, or, with Entra configured, sign in with Microsoft;
221
+ the page shows each held call with its full arguments, checked against
222
+ the receipt, and signs the grant locally with WebCrypto. The key never
223
+ leaves the browser, and with Entra it is generated there, non-exportable,
224
+ and enrolled under the approver's UPN, so grants and audits name the
225
+ person. Then paste the server entry from
223
226
  `gargoyle-deploy/mcp-config.json` into your MCP client.
224
227
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
225
228
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -260,6 +263,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
260
263
  | `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
261
264
  | issuer `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
262
265
  | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
266
+ | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
267
+ | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
263
268
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
264
269
  | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
265
270
 
@@ -0,0 +1,116 @@
1
+ """Enrolled approvers: a person's Entra identity bound to a key they hold.
2
+
3
+ `gargoyle init` writes approver key files. For a team, an approver should
4
+ instead sign in with Entra ID in the console, generate a key that never
5
+ leaves their browser, and enroll its public half once. The issuer accepts
6
+ the enrollment from a token that carries the approver app role (or a
7
+ configured group), binds the key to the token's UPN, and persists it here.
8
+ From then on the UPN is the approver key id on every grant, a threshold
9
+ rule may name UPNs, and removing a line from this file (or the role in
10
+ Entra) ends that person's ability to approve.
11
+
12
+ approvers.json = {"approvers": {"<upn>": {"public_key_hex": ..., "enrolled_at": ..., "enrolled_by": ...}}}
13
+
14
+ Trust note. The issuer publishes this map (GET /approvers) and the
15
+ middleware fetches it over the issuer's TLS. That makes the issuer's
16
+ operator the authority for who may approve, which is weaker than a
17
+ root-signed key record (SPEC.md section 10) and the right fit for a site
18
+ where the same administrator runs the issuer and the tenant. Separation of
19
+ duties still holds at the key level: an approver's key is never the
20
+ issuer's, and the issuer never sees a private key.
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import stat
26
+ import threading
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Any, Dict, Optional
30
+
31
+ ENV_APPROVERS_PATH = "GARGOYLE_APPROVERS_PATH"
32
+ ENV_APPROVER_ROLE = "GARGOYLE_APPROVER_ROLE" # Entra app role an enrolling token must carry (default Approver)
33
+ ENV_APPROVER_GROUPS = "GARGOYLE_APPROVER_GROUPS" # or: comma-separated group ids, any of which qualifies
34
+
35
+ DEFAULT_APPROVER_ROLE = "Approver"
36
+
37
+
38
+ def valid_public_key_hex(value: Any) -> bool:
39
+ return isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
40
+
41
+
42
+ class ApproverRegistry:
43
+ """The enrolled approvers, persisted to a JSON file. Thread-safe for the
44
+ single process that owns the file."""
45
+
46
+ def __init__(self, path: Optional[str]) -> None:
47
+ self.path = Path(path) if path else None
48
+ self._lock = threading.Lock()
49
+ self._approvers: Dict[str, Dict[str, Any]] = {}
50
+ if self.path is not None and self.path.exists():
51
+ doc = json.loads(self.path.read_text(encoding="utf-8"))
52
+ for upn, entry in (doc.get("approvers") or {}).items():
53
+ if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
54
+ self._approvers[upn] = dict(entry)
55
+
56
+ def _save(self) -> None:
57
+ if self.path is None:
58
+ return
59
+ self.path.parent.mkdir(parents=True, exist_ok=True)
60
+ self.path.write_text(json.dumps({"approvers": self._approvers}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
61
+ try:
62
+ os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
63
+ except OSError:
64
+ pass
65
+
66
+ def enroll(self, upn: str, public_key_hex: str, *, enrolled_by: Optional[str] = None,
67
+ now: Optional[datetime] = None) -> Dict[str, Any]:
68
+ """Bind `upn` to `public_key_hex`, replacing any earlier key for that
69
+ UPN (one key per approver; a new browser re-enrolls)."""
70
+ if not isinstance(upn, str) or not upn:
71
+ raise ValueError("upn must be a non-empty string")
72
+ if not valid_public_key_hex(public_key_hex):
73
+ raise ValueError("public_key_hex must be 64 lowercase hex characters")
74
+ entry = {"public_key_hex": public_key_hex,
75
+ "enrolled_at": (now or datetime.now(timezone.utc)).isoformat(),
76
+ "enrolled_by": enrolled_by or upn}
77
+ with self._lock:
78
+ replaced = upn in self._approvers
79
+ self._approvers[upn] = entry
80
+ self._save()
81
+ return {"upn": upn, "replaced": replaced, **entry}
82
+
83
+ def remove(self, upn: str) -> bool:
84
+ with self._lock:
85
+ gone = self._approvers.pop(upn, None) is not None
86
+ if gone:
87
+ self._save()
88
+ return gone
89
+
90
+ def public_hex(self) -> Dict[str, str]:
91
+ """{upn: public_key_hex}, what GET /approvers publishes."""
92
+ with self._lock:
93
+ return {upn: e["public_key_hex"] for upn, e in self._approvers.items()}
94
+
95
+ def keys(self) -> Dict[str, Any]:
96
+ """{upn: Ed25519PublicKey}, the map a verifier uses."""
97
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
98
+ return {upn: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h)) for upn, h in self.public_hex().items()}
99
+
100
+ def __contains__(self, upn: str) -> bool:
101
+ return upn in self._approvers
102
+
103
+
104
+ def may_enroll(who: Dict[str, Any], *, role: str = DEFAULT_APPROVER_ROLE, groups: Optional[list] = None) -> bool:
105
+ """Does an authenticated identity (identity.principal_from_token's
106
+ result) qualify as an approver: the app role, or any configured group."""
107
+ if role and role in (who.get("roles") or []):
108
+ return True
109
+ if groups and any(g in (who.get("groups") or []) for g in groups):
110
+ return True
111
+ return False
112
+
113
+
114
+ def keys_from_hex(mapping: Dict[str, str]) -> Dict[str, Any]:
115
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
116
+ return {k: Ed25519PublicKey.from_public_bytes(bytes.fromhex(v)) for k, v in mapping.items() if valid_public_key_hex(v)}
@@ -194,6 +194,7 @@ def cmd_issuer(args) -> int:
194
194
 
195
195
  out = Path(args.out).resolve()
196
196
  os.environ.update(_read_env(out / "issuer.env"))
197
+ os.environ.setdefault("GARGOYLE_APPROVERS_PATH", str(out / "approvers.json")) # enrolled approvers live beside the keys
197
198
  app = app_from_env()
198
199
  # The middleware reads the policy pin from a file; write it next to the
199
200
  # config so the client-side entry finds it at the configured path.
@@ -229,8 +230,12 @@ def cmd_audit(args) -> int:
229
230
 
230
231
  keys = json.loads((Path(args.out).resolve() / "public-keys.json").read_text())
231
232
  load = lambda m: {k: Ed25519PublicKey.from_public_bytes(bytes.fromhex(v)) for k, v in m.items()}
233
+ approvers = dict(keys["approver"])
234
+ enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
235
+ if enrolled.exists():
236
+ approvers.update({u: e["public_key_hex"] for u, e in json.loads(enrolled.read_text()).get("approvers", {}).items()})
232
237
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
233
- approver_public_keys=load(keys["approver"]))
238
+ approver_public_keys=load(approvers))
234
239
  if args.log:
235
240
  # The middleware's evidence log: every decision, honored or refused,
236
241
  # hash-chained. Each honored entry is a full receipt chain.
@@ -152,6 +152,7 @@ class Consumer:
152
152
  clock_skew: timedelta = timedelta(0),
153
153
  high_water: Optional[HighWater] = None,
154
154
  approver_public_keys: Optional[Dict[str, Any]] = None,
155
+ approver_key_source: Optional[Callable[[], Dict[str, Any]]] = None,
155
156
  executor_signing_key: Any = None,
156
157
  executor_key_id: Optional[str] = None,
157
158
  ) -> None:
@@ -195,6 +196,9 @@ class Consumer:
195
196
  self.clock_skew = clock_skew
196
197
  self.high_water = high_water if high_water is not None else HighWater()
197
198
  self.approver_public_keys = approver_public_keys
199
+ # Enrolled approvers (gargoyle.approvers), fetched live by the caller's
200
+ # function and merged over the static map at each flagged check.
201
+ self.approver_key_source = approver_key_source
198
202
 
199
203
  # ----- the live policy pin -----
200
204
 
@@ -253,7 +257,13 @@ class Consumer:
253
257
  if key_record_view is not None:
254
258
  return resolve_approver_keys(key_record_view, gate_key_id=gate_key_id,
255
259
  now=now, clock_skew=self.clock_skew)
256
- return dict(self.approver_public_keys or {})
260
+ keys = dict(self.approver_public_keys or {})
261
+ if self.approver_key_source is not None:
262
+ try:
263
+ keys.update({k: v for k, v in (self.approver_key_source() or {}).items() if k != gate_key_id})
264
+ except Exception:
265
+ pass # an unreachable source adds nothing; the static map stands
266
+ return keys
257
267
 
258
268
  def check(
259
269
  self,
@@ -53,6 +53,8 @@ from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_
53
53
  verify_grant)
54
54
  from gargoyle.envelope import approval_terms
55
55
  from gargoyle.notify import notifier_from_env
56
+ from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_APPROVER_ROLE, ENV_APPROVERS_PATH,
57
+ ApproverRegistry, may_enroll, valid_public_key_hex)
56
58
  from gargoyle.identity import entra_keys, principal_from_token
57
59
  from gargoyle.issuer import DEFAULT_TTL_SECONDS, issue
58
60
  from gargoyle.policy import Policy
@@ -69,12 +71,15 @@ ENV_AUDIENCE = "GARGOYLE_ISSUER_AUDIENCE"
69
71
  ENV_APPROVER_KEY_ID = "GARGOYLE_APPROVER_KEY_ID"
70
72
  ENV_APPROVER_PUBLIC_KEY_HEX = "GARGOYLE_APPROVER_PUBLIC_KEY_HEX"
71
73
  ENV_RECEIPT_TTL = "GARGOYLE_RECEIPT_TTL_SECONDS"
74
+ ENV_CONSOLE_CLIENT_ID = "GARGOYLE_ENTRA_CLIENT_ID" # the public client app the console signs in with (User-Client-Flow)
75
+ ENV_CONSOLE_SCOPE = "GARGOYLE_ENTRA_SCOPE" # default api://<last audience>/access
72
76
 
73
77
  REF_ISSUER_UNAUTHENTICATED = "REF_ISSUER_UNAUTHENTICATED"
74
78
  REF_ISSUER_BAD_REQUEST = "REF_ISSUER_BAD_REQUEST"
75
79
  REF_ISSUER_ARGS_NOT_TEXT = "REF_ISSUER_ARGS_NOT_TEXT" # arguments carry characters no UTF-8 text has
76
80
  REF_ISSUER_UNKNOWN_HOLD = "REF_ISSUER_UNKNOWN_HOLD"
77
81
  REF_ISSUER_NO_APPROVER_TRUST = "REF_ISSUER_NO_APPROVER_TRUST"
82
+ REF_ISSUER_NOT_AN_APPROVER = "REF_ISSUER_NOT_AN_APPROVER" # enrollment without the approver role or group
78
83
 
79
84
 
80
85
  def build_issuer_app(
@@ -88,6 +93,10 @@ def build_issuer_app(
88
93
  ttl_seconds: int = DEFAULT_TTL_SECONDS,
89
94
  now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
90
95
  on_hold: Optional[Callable[[Dict[str, Any], Any], None]] = None,
96
+ approver_registry: Optional[ApproverRegistry] = None,
97
+ approver_role: str = DEFAULT_APPROVER_ROLE,
98
+ approver_groups: Optional[list] = None,
99
+ console_entra: Optional[Dict[str, str]] = None,
91
100
  ) -> FastAPI:
92
101
  """`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
93
102
  posts it to a webhook); it is notice, not admission, and its failure
@@ -97,6 +106,12 @@ def build_issuer_app(
97
106
  app.state.pending_args: Dict[str, Any] = {} # approval_request_id -> the call's arguments
98
107
  app.state.grants: Dict[str, Dict[str, Any]] = {} # approval_request_id -> grant
99
108
  app.state.declined: set = set() # approval_request_ids a human refused
109
+ registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
110
+ static_approvers = dict(approver_public_keys or {})
111
+
112
+ def approver_keys() -> Dict[str, Any]:
113
+ """Static keys from configuration plus every enrolled approver, by UPN."""
114
+ return {**static_approvers, **registry.keys()}
100
115
 
101
116
  def _refuse(status: int, reason: str) -> HTTPException:
102
117
  return HTTPException(status_code=status, detail={"reason": reason})
@@ -182,10 +197,11 @@ def build_issuer_app(
182
197
  env = app.state.pending.get(arid) if isinstance(arid, str) else None
183
198
  if env is None:
184
199
  return {"accepted": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
185
- if not approver_public_keys:
200
+ trusted = approver_keys()
201
+ if not trusted:
186
202
  return {"accepted": False, "reason": REF_ISSUER_NO_APPROVER_TRUST}
187
203
  g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"],
188
- expected_approval_request_id=arid, approver_public_keys=approver_public_keys,
204
+ expected_approval_request_id=arid, approver_public_keys=trusted,
189
205
  gate_key_id=key_id, now=now())
190
206
  if not g["accepted"]:
191
207
  return {"accepted": False, "reason": g["reason"]}
@@ -230,6 +246,46 @@ def build_issuer_app(
230
246
  "collected": len(have), "required": approval_terms(env)["required"] if env else None,
231
247
  "declined": approval_request_id in app.state.declined}
232
248
 
249
+ @app.get("/approvers")
250
+ async def approvers():
251
+ """Public, like /record: the enrolled approvers' public keys by UPN,
252
+ for the middleware and for audits. Static configured keys are not
253
+ listed; they travel in config."""
254
+ return {"approvers": registry.public_hex()}
255
+
256
+ @app.post("/approvers/enroll")
257
+ async def enroll(request: Request, authorization: Optional[str] = Header(default=None)):
258
+ """Bind the caller's UPN to the public key their browser generated.
259
+ Only a token carrying the approver role (or a configured group) may;
260
+ the UPN comes from the token, never from the body."""
261
+ who = authenticate(authorization)
262
+ if not may_enroll(who, role=approver_role, groups=approver_groups):
263
+ raise _refuse(403, REF_ISSUER_NOT_AN_APPROVER)
264
+ try:
265
+ body = await request.json()
266
+ except Exception:
267
+ raise _refuse(400, REF_ISSUER_BAD_REQUEST)
268
+ if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
269
+ raise _refuse(400, REF_ISSUER_BAD_REQUEST)
270
+ result = registry.enroll(who["principal"], body["public_key_hex"], now=now())
271
+ return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
272
+
273
+ @app.get("/me")
274
+ async def me(authorization: Optional[str] = Header(default=None)):
275
+ """Who the console is signed in as, whether they may approve, and
276
+ the public key enrolled for them (so the browser can tell whether
277
+ its own key is the one on record)."""
278
+ who = authenticate(authorization)
279
+ upn = who["principal"]
280
+ return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
281
+ "enrolled_public_key_hex": registry.public_hex().get(upn)}
282
+
283
+ @app.get("/approve-config")
284
+ async def approve_config():
285
+ """Public: how the console signs in. `entra` is null when the site
286
+ uses approver files instead."""
287
+ return {"entra": console_entra}
288
+
233
289
  @app.get("/approve")
234
290
  async def approve_page():
235
291
  return Response((STATIC / "approve.html").read_bytes(), media_type="text/html; charset=utf-8")
@@ -298,4 +354,10 @@ def app_from_env() -> FastAPI:
298
354
  audience=os.environ.get(ENV_AUDIENCE) or None,
299
355
  ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
300
356
  on_hold=notifier_from_env(os.environ),
357
+ approver_registry=ApproverRegistry(os.environ.get(ENV_APPROVERS_PATH)),
358
+ approver_role=os.environ.get(ENV_APPROVER_ROLE, DEFAULT_APPROVER_ROLE),
359
+ approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,
360
+ console_entra=({"tenant": tenant, "client_id": os.environ[ENV_CONSOLE_CLIENT_ID],
361
+ "scope": os.environ.get(ENV_CONSOLE_SCOPE) or "api://%s/access" % (os.environ.get(ENV_AUDIENCE) or "").split(",")[-1].strip()}
362
+ if tenant and os.environ.get(ENV_CONSOLE_CLIENT_ID) else None),
301
363
  )
@@ -160,6 +160,16 @@ class IssuerClient:
160
160
  return {"envelope": None, "reason": body.get("reason") or REF_TARGET_ISSUER_UNAVAILABLE}
161
161
  return {"envelope": body.get("envelope"), "reason": None}
162
162
 
163
+ def approver_keys(self) -> Dict[str, Any]:
164
+ """The issuer's enrolled approvers as {upn: public key}; {} when the
165
+ issuer cannot be reached (the static map then stands alone)."""
166
+ from gargoyle.approvers import keys_from_hex
167
+ try:
168
+ r = self._get("/approvers")
169
+ return keys_from_hex(r.json().get("approvers", {})) if r.status_code == 200 else {}
170
+ except Exception:
171
+ return {}
172
+
163
173
  def wait_for_grant(self, approval_request_id: str) -> Dict[str, Any]:
164
174
  """Poll until the hold has its grant (or, for a receipt that needs
165
175
  several approvers, the whole set), a human declined it, the hold
@@ -284,6 +294,7 @@ class ServerState:
284
294
  record=config["record"],
285
295
  replay_cache=replay_cache,
286
296
  approver_public_keys=config.get("approver_public_keys"),
297
+ approver_key_source=(issuer_client.approver_keys if issuer_client is not None else None),
287
298
  executor_signing_key=config.get("executor_signing_key"),
288
299
  executor_key_id=config.get("executor_key_id"),
289
300
  )
@@ -34,6 +34,11 @@
34
34
  <span class="status" id="status">not loaded</span>
35
35
  </header>
36
36
  <main>
37
+ <section class="setup" id="entra" style="display:none">
38
+ <strong>Sign in with Microsoft</strong> to approve as yourself. Your signing key is created in this browser,
39
+ cannot be exported, and is enrolled under your account the first time.
40
+ <div style="margin-top:10px"><button id="signin">Sign in with Microsoft</button> <span id="entra-note"></span></div>
41
+ </section>
37
42
  <section class="setup" id="setup">
38
43
  <strong>Load your approver file</strong> (the <code>approver-web.json</code> that <code>gargoyle init</code> wrote).
39
44
  It holds your key and your token; they stay in this tab and are gone when you close it.
@@ -43,12 +48,58 @@
43
48
  <div class="log" id="log"></div>
44
49
  </main>
45
50
  <script type="module">
46
- import { argsSha256, buildGrant, importApproverKey, isoUtc, newGrantId, signGrant } from "./approve.js";
51
+ import { argsSha256, authorizeUrl, buildGrant, exchangeCode, generateApproverKey, importApproverKey, isoUtc, loadStoredKey,
52
+ newGrantId, pkceChallenge, publicKeyHex, randomVerifier, signGrant, storeKey } from "./approve.js";
47
53
 
48
54
  const state = { token: null, key: null, keyId: null, ttl: 300, timer: null, seen: new Map() };
49
55
  const $ = (id) => document.getElementById(id);
50
56
  const log = (m) => { $("log").textContent = new Date().toLocaleTimeString() + " " + m; };
51
57
 
58
+ // ---- Entra path: sign in, hold a browser key, enroll it once ----
59
+ const redirectUri = location.origin + location.pathname;
60
+ async function entraSetup() {
61
+ let cfg = null;
62
+ try { cfg = (await (await fetch("approve-config")).json()).entra; } catch (err) { /* older issuer: files only */ }
63
+ if (!cfg) return;
64
+ $("entra").style.display = "";
65
+ const params = new URLSearchParams(location.search);
66
+ if (params.get("code")) { // back from Microsoft with a code
67
+ history.replaceState(null, "", redirectUri);
68
+ const verifier = sessionStorage.getItem("gargoyle-pkce"), expected = sessionStorage.getItem("gargoyle-state");
69
+ if (!verifier || params.get("state") !== expected) { log("sign-in state mismatch; try again"); return; }
70
+ try {
71
+ const tok = await exchangeCode({ tenant: cfg.tenant, clientId: cfg.client_id, scope: cfg.scope, redirectUri, code: params.get("code"), verifier });
72
+ sessionStorage.setItem("gargoyle-token", tok.access_token);
73
+ } catch (err) { log("sign-in failed: " + err.message); return; }
74
+ }
75
+ const token = sessionStorage.getItem("gargoyle-token");
76
+ if (token) { state.token = token; await entraReady(); return; }
77
+ $("signin").addEventListener("click", async () => {
78
+ const verifier = randomVerifier(), st = randomVerifier();
79
+ sessionStorage.setItem("gargoyle-pkce", verifier); sessionStorage.setItem("gargoyle-state", st);
80
+ location.href = authorizeUrl({ tenant: cfg.tenant, clientId: cfg.client_id, scope: cfg.scope, redirectUri, challenge: await pkceChallenge(verifier), state: st });
81
+ });
82
+ }
83
+
84
+ async function entraReady() {
85
+ let me;
86
+ try { me = await api("me"); } catch (err) { sessionStorage.removeItem("gargoyle-token"); log("session expired; sign in again"); return; }
87
+ if (!me.may_approve) { $("entra-note").textContent = `${me.principal} is signed in but has no Approver role`; return; }
88
+ let keyPair = await loadStoredKey(me.principal);
89
+ if (!keyPair) { keyPair = await generateApproverKey(); await storeKey(me.principal, keyPair); }
90
+ const mine = await publicKeyHex(keyPair);
91
+ if (me.enrolled_public_key_hex !== mine) {
92
+ // First time in this browser (or a new browser): enroll this key under the UPN.
93
+ const res = await api("approvers/enroll", { method: "POST", body: JSON.stringify({ public_key_hex: mine }) });
94
+ log(res.replaced ? "this browser's key replaced your previous one" : "this browser's key enrolled");
95
+ }
96
+ state.key = keyPair.privateKey; state.keyId = me.principal;
97
+ $("entra").style.display = "none"; $("setup").style.display = "none";
98
+ $("status").textContent = "approving as " + state.keyId;
99
+ await refresh(); state.timer = setInterval(refresh, 3000);
100
+ }
101
+ entraSetup();
102
+
52
103
  $("file").addEventListener("change", async (e) => {
53
104
  const f = e.target.files[0]; if (!f) return;
54
105
  try {
@@ -0,0 +1,121 @@
1
+ // Gargoyle approval console: the browser-side half of a human approval.
2
+ // The approver's Ed25519 key never leaves this page; the issuer receives only
3
+ // the signed grant, exactly as it does from gargoyle-approver. canonicalJson,
4
+ // argsSha256 and grant signing match SPEC.md byte for byte (tests/test_console_js.py
5
+ // checks them against the conformance vectors under Node).
6
+
7
+ // RFC 8785 canonical JSON. Scalars through JSON.stringify (its escaping and
8
+ // number format are the JCS rules); objects emitted by hand, because
9
+ // JSON.stringify moves integer-like member names to the front.
10
+ export function canonicalJson(value) {
11
+ if (value === null || typeof value !== "object") {
12
+ if (typeof value === "number" && !Number.isFinite(value)) throw new TypeError("non-finite numbers are not JSON");
13
+ if (value === undefined) throw new TypeError("value is not JSON: undefined");
14
+ return JSON.stringify(value);
15
+ }
16
+ if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]";
17
+ const keys = Object.keys(value).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
18
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
19
+ }
20
+
21
+ const subtle = () => globalThis.crypto.subtle;
22
+ const hex = (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
23
+
24
+ export async function sha256Hex(text) {
25
+ return hex(await subtle().digest("SHA-256", new TextEncoder().encode(text)));
26
+ }
27
+
28
+ export async function argsSha256(args) {
29
+ return sha256Hex(canonicalJson(args));
30
+ }
31
+
32
+ /** Import an approver key from the JSON `gargoyle init` wrote: {kty:"OKP", crv:"Ed25519", d, x}. */
33
+ export async function importApproverKey(jwk) {
34
+ return subtle().importKey("jwk", { kty: "OKP", crv: "Ed25519", d: jwk.d, x: jwk.x }, { name: "Ed25519" }, false, ["sign"]);
35
+ }
36
+
37
+ export function buildGrant({ decisionSha256, approvalRequestId, grantId, notAfter }) {
38
+ if (!grantId) throw new Error("grant_id is mandatory");
39
+ return { grant_version: "1.0", decision_sha256: decisionSha256, approval_request_id: approvalRequestId, grant_id: grantId, not_after: notAfter };
40
+ }
41
+
42
+ export async function signGrant(grant, key, keyId) {
43
+ const signed = { ...grant, approver_key_id: keyId };
44
+ const region = {};
45
+ for (const [k, v] of Object.entries(signed)) if (k !== "approver_signature") region[k] = v;
46
+ const sig = await subtle().sign({ name: "Ed25519" }, key, new TextEncoder().encode(canonicalJson(region)));
47
+ signed.approver_signature = hex(sig);
48
+ return signed;
49
+ }
50
+
51
+ /** ISO-8601 with an explicit +00:00 offset, as the reference writes it. */
52
+ export function isoUtc(date) {
53
+ return date.toISOString().replace(/\.\d{3}Z$/, "+00:00");
54
+ }
55
+
56
+ export function newGrantId() {
57
+ const b = new Uint8Array(16); globalThis.crypto.getRandomValues(b);
58
+ return hex(b.buffer);
59
+ }
60
+
61
+ // ---- Entra ID sign-in (OAuth 2.0 authorization code with PKCE, RFC 7636), no library ----
62
+
63
+ const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
64
+
65
+ export function randomVerifier() {
66
+ const bytes = new Uint8Array(32); globalThis.crypto.getRandomValues(bytes);
67
+ return b64url(bytes); // 43 chars of the unreserved set
68
+ }
69
+
70
+ export async function pkceChallenge(verifier) {
71
+ return b64url(await subtle().digest("SHA-256", new TextEncoder().encode(verifier)));
72
+ }
73
+
74
+ export function authorizeUrl({ tenant, clientId, scope, redirectUri, challenge, state }) {
75
+ const q = new URLSearchParams({ client_id: clientId, response_type: "code", redirect_uri: redirectUri, response_mode: "query",
76
+ scope: scope + " openid profile", code_challenge: challenge, code_challenge_method: "S256", state });
77
+ return `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?${q}`;
78
+ }
79
+
80
+ export async function exchangeCode({ tenant, clientId, scope, redirectUri, code, verifier }, fetchImpl = globalThis.fetch) {
81
+ const body = new URLSearchParams({ client_id: clientId, grant_type: "authorization_code", code, redirect_uri: redirectUri,
82
+ code_verifier: verifier, scope: scope + " openid profile" });
83
+ const r = await fetchImpl(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
84
+ { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body });
85
+ const tok = await r.json();
86
+ if (!r.ok || !tok.access_token) throw new Error(tok.error_description || tok.error || ("token endpoint " + r.status));
87
+ return tok; // { access_token, expires_in, id_token, ... }
88
+ }
89
+
90
+ // ---- The approver's own key: generated here, non-extractable, kept in IndexedDB ----
91
+
92
+ export async function generateApproverKey() {
93
+ return subtle().generateKey({ name: "Ed25519" }, false, ["sign", "verify"]); // private half can never be exported
94
+ }
95
+
96
+ export async function publicKeyHex(keyPair) {
97
+ return hex(await subtle().exportKey("raw", keyPair.publicKey));
98
+ }
99
+
100
+ const DB = "gargoyle-approver", STORE = "keys";
101
+ function openDb() {
102
+ return new Promise((resolve, reject) => {
103
+ const req = indexedDB.open(DB, 1);
104
+ req.onupgradeneeded = () => req.result.createObjectStore(STORE);
105
+ req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error);
106
+ });
107
+ }
108
+ export async function loadStoredKey(name) {
109
+ const db = await openDb();
110
+ return new Promise((resolve, reject) => {
111
+ const req = db.transaction(STORE).objectStore(STORE).get(name);
112
+ req.onsuccess = () => resolve(req.result || null); req.onerror = () => reject(req.error);
113
+ });
114
+ }
115
+ export async function storeKey(name, keyPair) {
116
+ const db = await openDb();
117
+ return new Promise((resolve, reject) => {
118
+ const tx = db.transaction(STORE, "readwrite"); tx.objectStore(STORE).put(keyPair, name);
119
+ tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error);
120
+ });
121
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.5.3.dev0
3
+ Version: 0.6.0.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.5.3.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.6.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -255,9 +255,12 @@ Three commands, no policy engine, no containers, no client changes:
255
255
  gargoyle approve --watch 2 # where the approver sits (terminal)
256
256
 
257
257
  or approve in a browser: open the issuer's `/approve` page and load
258
- `approver-web.json`; the page shows each held call with its full arguments,
259
- checked against the receipt, and signs the grant locally with WebCrypto.
260
- The key never leaves the browser. Then paste the server entry from
258
+ `approver-web.json`, or, with Entra configured, sign in with Microsoft;
259
+ the page shows each held call with its full arguments, checked against
260
+ the receipt, and signs the grant locally with WebCrypto. The key never
261
+ leaves the browser, and with Entra it is generated there, non-exportable,
262
+ and enrolled under the approver's UPN, so grants and audits name the
263
+ person. Then paste the server entry from
261
264
  `gargoyle-deploy/mcp-config.json` into your MCP client.
262
265
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
263
266
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -298,6 +301,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
298
301
  | `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
299
302
  | issuer `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
300
303
  | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
304
+ | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
305
+ | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
301
306
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
302
307
  | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
303
308
 
@@ -6,6 +6,7 @@ gargoyle/__init__.py
6
6
  gargoyle/approval.py
7
7
  gargoyle/approver_cli.py
8
8
  gargoyle/approver_trust.py
9
+ gargoyle/approvers.py
9
10
  gargoyle/cli.py
10
11
  gargoyle/consumer.py
11
12
  gargoyle/entra_login.py
@@ -37,6 +38,7 @@ gargoyle_receipts.egg-info/requires.txt
37
38
  gargoyle_receipts.egg-info/top_level.txt
38
39
  tests/test_annotations.py
39
40
  tests/test_approver_cli.py
41
+ tests/test_approvers.py
40
42
  tests/test_canonical.py
41
43
  tests/test_cli.py
42
44
  tests/test_console_js.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.5.3.dev0"
7
+ version = "0.6.0.dev0"
8
8
  description = "Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,111 @@
1
+ """Approvers who sign in with Entra: a browser-held key enrolled under the
2
+ token's UPN, published by the issuer, trusted by the middleware and the
3
+ audit, and usable in threshold rules by name."""
4
+
5
+ import json
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ import pytest
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
10
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
11
+ from fastapi.testclient import TestClient
12
+
13
+ from gargoyle.approval import build_grant, sign_grant
14
+ from gargoyle.approvers import ApproverRegistry, may_enroll
15
+ from gargoyle.consumer import Consumer
16
+ from gargoyle.issuer_service import REF_ISSUER_NOT_AN_APPROVER, build_issuer_app
17
+ from gargoyle.jws import encode
18
+ from gargoyle.policy import ELIGIBLE, Decision
19
+
20
+ issuer, idp = Ed25519PrivateKey.generate(), Ed25519PrivateKey.generate()
21
+ PIN = {"policy_id": "p/1", "policy_sha256": "a" * 64}
22
+ TARGET = "mcp://fs"
23
+ pub_hex = lambda k: k.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
24
+
25
+
26
+ class Hold:
27
+ def __init__(self, required=1, approvers=None): self.d = Decision(ELIGIBLE, None, True, required, approvers)
28
+ def pin(self): return PIN
29
+ def decide(self, target_url, interaction): return self.d
30
+
31
+
32
+ def token(upn, roles=(), groups=()):
33
+ claims = {"preferred_username": upn, "sub": "oid-" + upn, "exp": int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp())}
34
+ if roles: claims["roles"] = list(roles)
35
+ if groups: claims["groups"] = list(groups)
36
+ return encode(claims, idp, "idp-1")
37
+
38
+
39
+ def test_registry_persists_one_key_per_upn(tmp_path):
40
+ path = tmp_path / "approvers.json"
41
+ reg = ApproverRegistry(str(path))
42
+ k1, k2 = Ed25519PrivateKey.generate(), Ed25519PrivateKey.generate()
43
+ r = reg.enroll("dana@contoso.com", pub_hex(k1))
44
+ assert r["replaced"] is False and "dana@contoso.com" in reg
45
+ assert reg.enroll("dana@contoso.com", pub_hex(k2))["replaced"] is True
46
+ again = ApproverRegistry(str(path)) # survives a restart
47
+ assert again.public_hex() == {"dana@contoso.com": pub_hex(k2)}
48
+ assert set(again.keys()) == {"dana@contoso.com"}
49
+ assert again.remove("dana@contoso.com") and not again.remove("dana@contoso.com")
50
+ with pytest.raises(ValueError):
51
+ reg.enroll("x@y", "zz")
52
+ assert may_enroll({"roles": ["Approver"]}) and not may_enroll({"roles": ["Reader"]})
53
+ assert may_enroll({"groups": ["g-1"]}, groups=["g-1"]) and not may_enroll({"groups": ["g-2"]}, groups=["g-1"])
54
+
55
+
56
+ def _app(tmp_path, policy=None, groups=None):
57
+ reg = ApproverRegistry(str(tmp_path / "approvers.json"))
58
+ app = build_issuer_app(policy=policy or Hold(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
59
+ approver_registry=reg, approver_groups=groups,
60
+ console_entra={"tenant": "t", "client_id": "c", "scope": "api://a/access"})
61
+ return TestClient(app), reg
62
+
63
+
64
+ def test_enrollment_needs_the_role_and_binds_the_tokens_upn(tmp_path):
65
+ c, reg = _app(tmp_path)
66
+ key = Ed25519PrivateKey.generate()
67
+ h = lambda t: {"Authorization": "Bearer " + t}
68
+ r = c.post("/approvers/enroll", json={"public_key_hex": pub_hex(key)}, headers=h(token("eve@contoso.com")))
69
+ assert r.status_code == 403 and r.json()["detail"]["reason"] == REF_ISSUER_NOT_AN_APPROVER
70
+ r = c.post("/approvers/enroll", json={"public_key_hex": pub_hex(key)}, headers=h(token("dana@contoso.com", roles=["Approver"])))
71
+ assert r.json() == {"enrolled": True, "key_id": "dana@contoso.com", "replaced": False}
72
+ assert c.get("/approvers").json() == {"approvers": {"dana@contoso.com": pub_hex(key)}} # public
73
+ me = c.get("/me", headers=h(token("dana@contoso.com", roles=["Approver"]))).json()
74
+ assert me == {"principal": "dana@contoso.com", "may_approve": True, "enrolled_public_key_hex": pub_hex(key)}
75
+ assert c.get("/me", headers=h(token("eve@contoso.com"))).json()["may_approve"] is False
76
+ assert c.get("/approve-config").json() == {"entra": {"tenant": "t", "client_id": "c", "scope": "api://a/access"}}
77
+ assert c.post("/approvers/enroll", json={"public_key_hex": "nope"}, headers=h(token("dana@contoso.com", roles=["Approver"]))).status_code == 400
78
+ # A group qualifies too.
79
+ c2, _ = _app(tmp_path / "g", groups=["grp-approvers"])
80
+ assert c2.post("/approvers/enroll", json={"public_key_hex": pub_hex(key)}, headers=h(token("sam@contoso.com", groups=["grp-approvers"]))).json()["enrolled"]
81
+
82
+
83
+ def test_an_enrolled_approver_releases_a_hold_and_the_middleware_trusts_the_published_key(tmp_path):
84
+ c, reg = _app(tmp_path, policy=Hold(2, ("dana@contoso.com", "lee@contoso.com")))
85
+ dana, lee = Ed25519PrivateKey.generate(), Ed25519PrivateKey.generate()
86
+ h = lambda t: {"Authorization": "Bearer " + t}
87
+ for upn, key in (("dana@contoso.com", dana), ("lee@contoso.com", lee)):
88
+ assert c.post("/approvers/enroll", json={"public_key_hex": pub_hex(key)}, headers=h(token(upn, roles=["Approver"]))).json()["enrolled"]
89
+ agent = token("agent@contoso.com")
90
+ env = c.post("/issue", json={"target_url": TARGET, "tool": "transfer_funds", "args": {"amount": 5}}, headers=h(agent)).json()["envelope"]
91
+ assert env["approval"] == {"required": 2, "approvers": ["dana@contoso.com", "lee@contoso.com"]}
92
+ arid = env["approval_request_id"]
93
+ g = lambda key, upn: sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=arid, grant_id="g-" + upn,
94
+ not_after=datetime.now(timezone.utc) + timedelta(minutes=5)), key, upn)
95
+ assert c.post("/grants", json=g(dana, "dana@contoso.com"), headers=h(agent)).json()["accepted"]
96
+ assert c.post("/grants", json=g(lee, "lee@contoso.com"), headers=h(agent)).json()["complete"] is True
97
+ grants = c.get("/grants/" + arid, headers=h(agent)).json()["grants"]
98
+ assert [x["approver_key_id"] for x in grants] == ["dana@contoso.com", "lee@contoso.com"]
99
+
100
+ # The middleware's consumer has no static approver keys; it learns them from the issuer.
101
+ from gargoyle.mcp_server import IssuerClient
102
+ class Shim:
103
+ def post(self, url, json=None, headers=None, timeout=None): return c.post(url.replace("http://i", ""), json=json, headers=headers)
104
+ def get(self, url, headers=None, timeout=None): return c.get(url.replace("http://i", ""), headers=headers)
105
+ ic = IssuerClient("http://i", agent, http=Shim())
106
+ assert set(ic.approver_keys()) == {"dana@contoso.com", "lee@contoso.com"}
107
+ consumer = Consumer(target_id=TARGET, pinned_public_keys={"issuer-1": issuer.public_key()}, record=PIN,
108
+ approver_key_source=ic.approver_keys)
109
+ assert consumer.check(env, {"tool": "transfer_funds", "args": {"amount": 5}, "principal": "agent@contoso.com"}, grant=grants).honored
110
+ # The audit command merges approvers.json from the deploy folder.
111
+ assert json.loads((tmp_path / "approvers.json").read_text())["approvers"]["lee@contoso.com"]["public_key_hex"] == pub_hex(lee)
@@ -37,3 +37,23 @@ def test_console_signing_matches_the_reference_vectors(tmp_path):
37
37
  assert out["args_sha256"] == vectors["args_sha256"]
38
38
  assert out["hash_region"] == vectors["hash_region"]
39
39
  assert out["grant"] == vectors["grant"] # byte-equal signature from the browser-side code
40
+
41
+
42
+ PKCE_SCRIPT = r'''
43
+ import { pkceChallenge, authorizeUrl } from "%s";
44
+ const challenge = await pkceChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk");
45
+ const url = authorizeUrl({ tenant: "t", clientId: "c", scope: "api://a/access", redirectUri: "https://g/approve", challenge, state: "s1" });
46
+ console.log(JSON.stringify({ challenge, url }));
47
+ '''
48
+
49
+
50
+ def test_console_pkce_matches_rfc_7636(tmp_path):
51
+ """Appendix B of RFC 7636: the S256 challenge for the given verifier."""
52
+ if shutil.which("node") is None:
53
+ pytest.skip("node not installed")
54
+ script = tmp_path / "pkce.mjs"
55
+ script.write_text(PKCE_SCRIPT % (ROOT / "gargoyle" / "static" / "approve.js").as_uri())
56
+ out = json.loads(subprocess.run(["node", str(script)], capture_output=True, text=True, check=True).stdout)
57
+ assert out["challenge"] == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
58
+ assert out["url"].startswith("https://login.microsoftonline.com/t/oauth2/v2.0/authorize?")
59
+ assert "code_challenge_method=S256" in out["url"] and "client_id=c" in out["url"] and "scope=api%3A%2F%2Fa%2Faccess+openid+profile" in out["url"]
@@ -1,59 +0,0 @@
1
- // Gargoyle approval console: the browser-side half of a human approval.
2
- // The approver's Ed25519 key never leaves this page; the issuer receives only
3
- // the signed grant, exactly as it does from gargoyle-approver. canonicalJson,
4
- // argsSha256 and grant signing match SPEC.md byte for byte (tests/test_console_js.py
5
- // checks them against the conformance vectors under Node).
6
-
7
- // RFC 8785 canonical JSON. Scalars through JSON.stringify (its escaping and
8
- // number format are the JCS rules); objects emitted by hand, because
9
- // JSON.stringify moves integer-like member names to the front.
10
- export function canonicalJson(value) {
11
- if (value === null || typeof value !== "object") {
12
- if (typeof value === "number" && !Number.isFinite(value)) throw new TypeError("non-finite numbers are not JSON");
13
- if (value === undefined) throw new TypeError("value is not JSON: undefined");
14
- return JSON.stringify(value);
15
- }
16
- if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]";
17
- const keys = Object.keys(value).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
18
- return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
19
- }
20
-
21
- const subtle = () => globalThis.crypto.subtle;
22
- const hex = (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
23
-
24
- export async function sha256Hex(text) {
25
- return hex(await subtle().digest("SHA-256", new TextEncoder().encode(text)));
26
- }
27
-
28
- export async function argsSha256(args) {
29
- return sha256Hex(canonicalJson(args));
30
- }
31
-
32
- /** Import an approver key from the JSON `gargoyle init` wrote: {kty:"OKP", crv:"Ed25519", d, x}. */
33
- export async function importApproverKey(jwk) {
34
- return subtle().importKey("jwk", { kty: "OKP", crv: "Ed25519", d: jwk.d, x: jwk.x }, { name: "Ed25519" }, false, ["sign"]);
35
- }
36
-
37
- export function buildGrant({ decisionSha256, approvalRequestId, grantId, notAfter }) {
38
- if (!grantId) throw new Error("grant_id is mandatory");
39
- return { grant_version: "1.0", decision_sha256: decisionSha256, approval_request_id: approvalRequestId, grant_id: grantId, not_after: notAfter };
40
- }
41
-
42
- export async function signGrant(grant, key, keyId) {
43
- const signed = { ...grant, approver_key_id: keyId };
44
- const region = {};
45
- for (const [k, v] of Object.entries(signed)) if (k !== "approver_signature") region[k] = v;
46
- const sig = await subtle().sign({ name: "Ed25519" }, key, new TextEncoder().encode(canonicalJson(region)));
47
- signed.approver_signature = hex(sig);
48
- return signed;
49
- }
50
-
51
- /** ISO-8601 with an explicit +00:00 offset, as the reference writes it. */
52
- export function isoUtc(date) {
53
- return date.toISOString().replace(/\.\d{3}Z$/, "+00:00");
54
- }
55
-
56
- export function newGrantId() {
57
- const b = new Uint8Array(16); globalThis.crypto.getRandomValues(b);
58
- return hex(b.buffer);
59
- }