gargoyle-receipts 0.5.3.dev0__tar.gz → 0.6.1.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.
- {gargoyle_receipts-0.5.3.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.6.1.dev0}/PKG-INFO +10 -5
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/README.md +9 -4
- gargoyle_receipts-0.6.1.dev0/gargoyle/approvers.py +116 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/cli.py +8 -1
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/consumer.py +11 -1
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/evidence.py +7 -4
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/issuer_service.py +64 -2
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/mcp_server.py +11 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/static/approve.html +52 -1
- gargoyle_receipts-0.6.1.dev0/gargoyle/static/approve.js +121 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0/gargoyle_receipts.egg-info}/PKG-INFO +10 -5
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/pyproject.toml +1 -1
- gargoyle_receipts-0.6.1.dev0/tests/test_approvers.py +111 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_cli.py +1 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_console_js.py +20 -0
- gargoyle_receipts-0.5.3.dev0/gargoyle/static/approve.js +0 -59
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.5.3.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.6.1.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1.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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
62
|
+
Status: pre-release (`0.6.1.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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.
|
|
24
|
+
Status: pre-release (`0.6.1.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
|
|
221
|
-
|
|
222
|
-
|
|
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(
|
|
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.
|
|
@@ -239,6 +244,8 @@ def cmd_audit(args) -> int:
|
|
|
239
244
|
for r in v["entries"]:
|
|
240
245
|
# A refused row shows why the executor refused, beside the check outcome.
|
|
241
246
|
why = (" (%s)" % r["verdict"]) if r["kind"] == "refused" and r["verdict"] else ""
|
|
247
|
+
if r.get("released_by"):
|
|
248
|
+
why += " released by " + ", ".join(r["released_by"])
|
|
242
249
|
print("%4s %-5s %-8s %-24s %-28s %s%s" % (r["seq"] if r["seq"] is not None else "?",
|
|
243
250
|
"ok" if r["accepted"] else "FAIL", r["kind"] or "?",
|
|
244
251
|
(r["tool"] or "?")[:24], (r["principal"] or "?")[:28], r["reason"], why))
|
|
@@ -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
|
-
|
|
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,
|
|
@@ -158,8 +158,9 @@ def verify_log(
|
|
|
158
158
|
and describe the call the entry names; a refused entry that carries a
|
|
159
159
|
receipt has it checked for issuer provenance. Returns
|
|
160
160
|
{"accepted", "reason", "tail_sha256", "count", "honored", "refused",
|
|
161
|
-
"entries": [{"seq", "kind", "tool", "principal", "accepted", "reason", "verdict"}, ...]},
|
|
162
|
-
`reason` the verification outcome
|
|
161
|
+
"entries": [{"seq", "kind", "tool", "principal", "accepted", "reason", "verdict", "released_by"}, ...]},
|
|
162
|
+
`reason` the verification outcome, `verdict` what the executor recorded,
|
|
163
|
+
`released_by` the approver key ids of the grants an honored held call carried.
|
|
163
164
|
Verification continues past a bad entry so the report names every one."""
|
|
164
165
|
trust = dict(issuer_public_keys=issuer_public_keys, executor_public_keys=executor_public_keys,
|
|
165
166
|
approver_public_keys=approver_public_keys)
|
|
@@ -177,7 +178,7 @@ def verify_log(
|
|
|
177
178
|
rc = rc if isinstance(rc, dict) else {}
|
|
178
179
|
report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
|
|
179
180
|
"accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
|
|
180
|
-
"verdict": None})
|
|
181
|
+
"verdict": None, "released_by": []})
|
|
181
182
|
all_ok = False
|
|
182
183
|
continue
|
|
183
184
|
e = raw
|
|
@@ -203,8 +204,10 @@ def verify_log(
|
|
|
203
204
|
now=_parse_at(e["at"]), historical=True)
|
|
204
205
|
ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
|
|
205
206
|
counts[e["kind"]] += 1
|
|
207
|
+
released_by = [g.get("approver_key_id") for g in (e["grant"] if isinstance(e["grant"], list) else [e["grant"]])
|
|
208
|
+
if isinstance(g, dict)] if e["grant"] is not None else []
|
|
206
209
|
report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
|
|
207
|
-
"accepted": ok, "reason": reason, "verdict": e["reason"]})
|
|
210
|
+
"accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by})
|
|
208
211
|
all_ok = all_ok and ok
|
|
209
212
|
# The chain continues from what the file says, so one bad entry is
|
|
210
213
|
# reported once rather than cascading into every later one.
|
|
@@ -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
|
-
|
|
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=
|
|
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,
|
|
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
|
+
}
|
{gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0/gargoyle_receipts.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1.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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
62
|
+
Status: pre-release (`0.6.1.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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
{gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle_receipts.egg-info/SOURCES.txt
RENAMED
|
@@ -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.
|
|
7
|
+
version = "0.6.1.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)
|
|
@@ -112,6 +112,7 @@ def test_init_for_a_windows_client_and_audit_log_verification(tmp_path, monkeypa
|
|
|
112
112
|
assert "4 entries (3 honored, 1 refused), 0 failed; chain EVIDENCE_VERIFIED" in out_text
|
|
113
113
|
assert out_text.count(" ok ") == 4 and "refused delete_file" in out_text
|
|
114
114
|
assert "ENTRY_VERIFIED (DESTRUCTIVE_TOOL)" in out_text
|
|
115
|
+
assert "released by approver-1" in out_text # the held write names who released it
|
|
115
116
|
# A tampered entry is caught, and the hash-chain names it: editing the
|
|
116
117
|
# execution receipt inside entry 2 changes its hash.
|
|
117
118
|
lines = log.read_text().splitlines(); e = json.loads(lines[1]); e["execution"]["outputs_sha256"] = "0" * 64
|
|
@@ -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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.5.3.dev0 → gargoyle_receipts-0.6.1.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|