gargoyle-receipts 0.4.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gargoyle/__init__.py +0 -0
- gargoyle/approval.py +260 -0
- gargoyle/approver_cli.py +228 -0
- gargoyle/approver_trust.py +95 -0
- gargoyle/cli.py +260 -0
- gargoyle/consumer.py +344 -0
- gargoyle/envelope.py +363 -0
- gargoyle/evidence.py +237 -0
- gargoyle/execution.py +212 -0
- gargoyle/fs_server.py +124 -0
- gargoyle/identity.py +114 -0
- gargoyle/issuer.py +104 -0
- gargoyle/issuer_service.py +297 -0
- gargoyle/jws.py +214 -0
- gargoyle/key_record_source.py +273 -0
- gargoyle/mcp_server.py +608 -0
- gargoyle/notify.py +80 -0
- gargoyle/opa.py +123 -0
- gargoyle/pending_store.py +222 -0
- gargoyle/policy.py +101 -0
- gargoyle/published_record_source.py +184 -0
- gargoyle/replay_cache.py +182 -0
- gargoyle/rules.py +174 -0
- gargoyle/static/approve.html +149 -0
- gargoyle/static/approve.js +59 -0
- gargoyle/verifier.py +399 -0
- gargoyle_receipts-0.4.0.dev0.dist-info/METADATA +361 -0
- gargoyle_receipts-0.4.0.dev0.dist-info/RECORD +33 -0
- gargoyle_receipts-0.4.0.dev0.dist-info/WHEEL +5 -0
- gargoyle_receipts-0.4.0.dev0.dist-info/entry_points.txt +3 -0
- gargoyle_receipts-0.4.0.dev0.dist-info/licenses/LICENSE +202 -0
- gargoyle_receipts-0.4.0.dev0.dist-info/licenses/NOTICE +6 -0
- gargoyle_receipts-0.4.0.dev0.dist-info/top_level.txt +1 -0
gargoyle/__init__.py
ADDED
|
File without changes
|
gargoyle/approval.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Approval grants: a human's signed release of a held, high-impact call.
|
|
2
|
+
|
|
3
|
+
When a Decision has requires_approval, the receipt is held until a human
|
|
4
|
+
approver, using their own key on their own device, signs a grant for exactly
|
|
5
|
+
that decision. A grant reuses the receipt's machinery: Ed25519 signatures
|
|
6
|
+
over canonical_json. The approver's key is a different identity from the
|
|
7
|
+
issuer's key.
|
|
8
|
+
|
|
9
|
+
verify_grant() is pure: it checks provenance, binding to the held decision
|
|
10
|
+
and request, separation of duties, and freshness. It does not consume the
|
|
11
|
+
grant. Single use is stateful and belongs to whoever enforces the grant:
|
|
12
|
+
the consumer (gargoyle.consumer) claims grant_id exactly once in its replay
|
|
13
|
+
cache when it honors a flagged receipt, and an issuer that holds flagged
|
|
14
|
+
receipts until the grant arrives consumes approval_request_id from its
|
|
15
|
+
pending set (gargoyle.pending_store). verify_grant only guarantees that
|
|
16
|
+
grant_id is present, so that claim cannot be skipped.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from gargoyle.envelope import approval_terms, canonical_json
|
|
23
|
+
from gargoyle.verifier import not_after_valid
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
GRANT_VERSION = "1.0"
|
|
27
|
+
|
|
28
|
+
# Keys excluded from the approver-signature region. The signature covers
|
|
29
|
+
# everything else - including grant_id, decision_sha256, approval_request_id,
|
|
30
|
+
# not_after, and approver_key_id - so all of them are tamper-proof.
|
|
31
|
+
_SIGNATURE_EXCLUDED_KEYS = ("approver_signature",)
|
|
32
|
+
|
|
33
|
+
# Refusal codes.
|
|
34
|
+
REF_APPROVAL_MALFORMED = "REF_APPROVAL_MALFORMED"
|
|
35
|
+
REF_APPROVAL_MISSING_GRANT_ID = "REF_APPROVAL_MISSING_GRANT_ID"
|
|
36
|
+
REF_APPROVAL_SIGNATURE_INVALID = "REF_APPROVAL_SIGNATURE_INVALID"
|
|
37
|
+
REF_APPROVAL_KEY_UNKNOWN = "REF_APPROVAL_KEY_UNKNOWN"
|
|
38
|
+
REF_APPROVAL_SOD = "REF_APPROVAL_SOD"
|
|
39
|
+
REF_APPROVAL_BINDING_MISMATCH = "REF_APPROVAL_BINDING_MISMATCH"
|
|
40
|
+
REF_APPROVAL_REQUEST_MISMATCH = "REF_APPROVAL_REQUEST_MISMATCH"
|
|
41
|
+
REF_APPROVAL_EXPIRED = "REF_APPROVAL_EXPIRED"
|
|
42
|
+
# Emitted by the caller that consumes grants, not by verify_grant: grant_id
|
|
43
|
+
# already claimed, or approval_request_id not pending or not bound to this
|
|
44
|
+
# decision.
|
|
45
|
+
REF_APPROVAL_REPLAY = "REF_APPROVAL_REPLAY"
|
|
46
|
+
REF_APPROVAL_REQUEST_UNKNOWN = "REF_APPROVAL_REQUEST_UNKNOWN"
|
|
47
|
+
|
|
48
|
+
# verify_grants: the set does not meet the receipt's terms.
|
|
49
|
+
REF_APPROVAL_INSUFFICIENT = "REF_APPROVAL_INSUFFICIENT" # fewer distinct approvers than required
|
|
50
|
+
REF_APPROVAL_DUPLICATE_APPROVER = "REF_APPROVAL_DUPLICATE_APPROVER" # the same key signed twice
|
|
51
|
+
REF_APPROVAL_APPROVER_NOT_ELIGIBLE = "REF_APPROVAL_APPROVER_NOT_ELIGIBLE" # a key the receipt does not name
|
|
52
|
+
|
|
53
|
+
ACCEPT_GRANT_VALID = "GRANT_VALID"
|
|
54
|
+
ACCEPT_GRANTS_SUFFICIENT = "GRANTS_SUFFICIENT"
|
|
55
|
+
|
|
56
|
+
# Top-level keys a usable grant must carry before verify_grant can run.
|
|
57
|
+
_REQUIRED_GRANT_KEYS = (
|
|
58
|
+
"grant_version",
|
|
59
|
+
"decision_sha256",
|
|
60
|
+
"approval_request_id",
|
|
61
|
+
"grant_id",
|
|
62
|
+
"not_after",
|
|
63
|
+
"approver_key_id",
|
|
64
|
+
"approver_signature",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_grant(
|
|
69
|
+
*,
|
|
70
|
+
decision_sha256: str,
|
|
71
|
+
approval_request_id: str,
|
|
72
|
+
grant_id: str,
|
|
73
|
+
not_after: datetime,
|
|
74
|
+
) -> Dict[str, Any]:
|
|
75
|
+
"""Construct an unsigned approval grant.
|
|
76
|
+
|
|
77
|
+
- decision_sha256: the exact decision approved. It covers the target,
|
|
78
|
+
the call's fields and the policy pin, so approving action A cannot
|
|
79
|
+
authorize a different action, argument set or target.
|
|
80
|
+
- approval_request_id: the id issued with the hold. decision_sha256 is
|
|
81
|
+
the same for every issuance of a decision, so the specific request is
|
|
82
|
+
identified here.
|
|
83
|
+
- grant_id: mandatory and unique; the key claimed exactly once for
|
|
84
|
+
single use.
|
|
85
|
+
- not_after: timezone-aware expiry; a naive datetime raises ValueError.
|
|
86
|
+
"""
|
|
87
|
+
if not isinstance(grant_id, str) or not grant_id:
|
|
88
|
+
raise ValueError("grant_id is mandatory and must be a non-empty string")
|
|
89
|
+
if not_after.tzinfo is None:
|
|
90
|
+
raise ValueError("not_after must be timezone-aware (UTC)")
|
|
91
|
+
return {
|
|
92
|
+
"grant_version": GRANT_VERSION,
|
|
93
|
+
"decision_sha256": decision_sha256,
|
|
94
|
+
"approval_request_id": approval_request_id,
|
|
95
|
+
"grant_id": grant_id,
|
|
96
|
+
"not_after": not_after.isoformat(),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def sign_grant(grant: Dict[str, Any], signing_key: Any, key_id: str) -> Dict[str, Any]:
|
|
101
|
+
"""Return a copy of `grant` signed with the approver's key.
|
|
102
|
+
|
|
103
|
+
`signing_key` is any object with .sign(bytes) -> bytes. The signature
|
|
104
|
+
covers everything except approver_signature, including approver_key_id,
|
|
105
|
+
so the claimed signer is bound too. The input is not modified.
|
|
106
|
+
"""
|
|
107
|
+
signed = dict(grant)
|
|
108
|
+
signed["approver_key_id"] = key_id
|
|
109
|
+
region = {k: v for k, v in signed.items() if k not in _SIGNATURE_EXCLUDED_KEYS}
|
|
110
|
+
signature = signing_key.sign(canonical_json(region).encode("utf-8"))
|
|
111
|
+
signed["approver_signature"] = signature.hex()
|
|
112
|
+
return signed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _reject(reason: str) -> Dict[str, Any]:
|
|
116
|
+
return {"accepted": False, "reason": reason}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _accept(reason: str = ACCEPT_GRANT_VALID) -> Dict[str, Any]:
|
|
120
|
+
return {"accepted": True, "reason": reason}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def verify_grant(
|
|
124
|
+
grant: Any,
|
|
125
|
+
*,
|
|
126
|
+
expected_decision_sha256: str,
|
|
127
|
+
expected_approval_request_id: str,
|
|
128
|
+
approver_public_keys: Dict[str, Any],
|
|
129
|
+
gate_key_id: str,
|
|
130
|
+
now: Optional[datetime] = None,
|
|
131
|
+
clock_skew: timedelta = timedelta(0),
|
|
132
|
+
) -> Dict[str, Any]:
|
|
133
|
+
"""Is `grant` a valid human release of the held decision?
|
|
134
|
+
|
|
135
|
+
Returns {"accepted", "reason"}. First failure wins:
|
|
136
|
+
1. structure -> REF_APPROVAL_MALFORMED
|
|
137
|
+
2. grant_id present -> REF_APPROVAL_MISSING_GRANT_ID
|
|
138
|
+
3. approver_key_id != gate_key_id -> REF_APPROVAL_SOD
|
|
139
|
+
4. approver key known -> REF_APPROVAL_KEY_UNKNOWN
|
|
140
|
+
5. signature verifies -> REF_APPROVAL_SIGNATURE_INVALID
|
|
141
|
+
6. decision_sha256 matches -> REF_APPROVAL_BINDING_MISMATCH
|
|
142
|
+
7. approval_request_id matches -> REF_APPROVAL_REQUEST_MISMATCH
|
|
143
|
+
8. not_after present and not passed -> REF_APPROVAL_EXPIRED
|
|
144
|
+
|
|
145
|
+
`gate_key_id` is the issuer's key id. Separation of duties is checked
|
|
146
|
+
before the signature, so a grant the issuer signed with its own key is
|
|
147
|
+
refused even when well-signed. That id check is only a backstop: the
|
|
148
|
+
real guarantee is that the issuer cannot hold an approver's private key,
|
|
149
|
+
and that `approver_public_keys` comes from a trust record the issuer's
|
|
150
|
+
operator does not control.
|
|
151
|
+
|
|
152
|
+
Single use is not enforced here; see the module docstring.
|
|
153
|
+
"""
|
|
154
|
+
# 1. structural presence
|
|
155
|
+
if not isinstance(grant, dict):
|
|
156
|
+
return _reject(REF_APPROVAL_MALFORMED)
|
|
157
|
+
for k in _REQUIRED_GRANT_KEYS:
|
|
158
|
+
if k not in grant:
|
|
159
|
+
return _reject(REF_APPROVAL_MALFORMED)
|
|
160
|
+
|
|
161
|
+
# 2. mandatory grant_id
|
|
162
|
+
grant_id = grant.get("grant_id")
|
|
163
|
+
if not isinstance(grant_id, str) or not grant_id:
|
|
164
|
+
return _reject(REF_APPROVAL_MISSING_GRANT_ID)
|
|
165
|
+
|
|
166
|
+
key_id = grant.get("approver_key_id")
|
|
167
|
+
signature_hex = grant.get("approver_signature")
|
|
168
|
+
if not isinstance(key_id, str) or not isinstance(signature_hex, str):
|
|
169
|
+
return _reject(REF_APPROVAL_SIGNATURE_INVALID)
|
|
170
|
+
|
|
171
|
+
# 3. separation of duties, before the signature: an issuer-signed
|
|
172
|
+
# approval is not oversight even if well-formed.
|
|
173
|
+
if key_id == gate_key_id:
|
|
174
|
+
return _reject(REF_APPROVAL_SOD)
|
|
175
|
+
|
|
176
|
+
# 4. known approver key
|
|
177
|
+
public_key = approver_public_keys.get(key_id)
|
|
178
|
+
if public_key is None:
|
|
179
|
+
return _reject(REF_APPROVAL_KEY_UNKNOWN)
|
|
180
|
+
|
|
181
|
+
# 5. signature verifies (duck-typed; covers everything but the signature)
|
|
182
|
+
region = {k: v for k, v in grant.items() if k not in _SIGNATURE_EXCLUDED_KEYS}
|
|
183
|
+
message = canonical_json(region).encode("utf-8")
|
|
184
|
+
try:
|
|
185
|
+
public_key.verify(bytes.fromhex(signature_hex), message)
|
|
186
|
+
except Exception:
|
|
187
|
+
return _reject(REF_APPROVAL_SIGNATURE_INVALID)
|
|
188
|
+
|
|
189
|
+
# 6. action binding
|
|
190
|
+
if grant.get("decision_sha256") != expected_decision_sha256:
|
|
191
|
+
return _reject(REF_APPROVAL_BINDING_MISMATCH)
|
|
192
|
+
|
|
193
|
+
# 7. request binding
|
|
194
|
+
if grant.get("approval_request_id") != expected_approval_request_id:
|
|
195
|
+
return _reject(REF_APPROVAL_REQUEST_MISMATCH)
|
|
196
|
+
|
|
197
|
+
# 8. freshness: not_after is mandatory, then the verifier's shared check.
|
|
198
|
+
not_after_raw = grant.get("not_after")
|
|
199
|
+
if not_after_raw is None:
|
|
200
|
+
return _reject(REF_APPROVAL_EXPIRED)
|
|
201
|
+
if not not_after_valid(not_after_raw, now=now, clock_skew=clock_skew):
|
|
202
|
+
return _reject(REF_APPROVAL_EXPIRED)
|
|
203
|
+
|
|
204
|
+
# 9. accept
|
|
205
|
+
return _accept()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def as_grant_list(grant: Any) -> List[Any]:
|
|
209
|
+
"""One grant or a list of them, as a list; None is the empty list."""
|
|
210
|
+
if grant is None:
|
|
211
|
+
return []
|
|
212
|
+
if isinstance(grant, list):
|
|
213
|
+
return grant
|
|
214
|
+
return [grant]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def verify_grants(
|
|
218
|
+
grants: Any,
|
|
219
|
+
*,
|
|
220
|
+
envelope: Dict[str, Any],
|
|
221
|
+
approver_public_keys: Dict[str, Any],
|
|
222
|
+
now: Optional[datetime] = None,
|
|
223
|
+
clock_skew: timedelta = timedelta(0),
|
|
224
|
+
) -> Dict[str, Any]:
|
|
225
|
+
"""Do these grants release the flagged `envelope` under its own terms?
|
|
226
|
+
|
|
227
|
+
The receipt's `approval` member (default: one grant from any trusted
|
|
228
|
+
approver) says how many distinct approvers are required and, when it
|
|
229
|
+
lists `approvers`, which key ids count. Each grant is checked with
|
|
230
|
+
verify_grant against the receipt's decision_sha256, approval_request_id
|
|
231
|
+
and issuer_key_id; the first failing grant's reason is the answer. Then:
|
|
232
|
+
a key named by no grant more than once (REF_APPROVAL_DUPLICATE_APPROVER),
|
|
233
|
+
every key eligible (REF_APPROVAL_APPROVER_NOT_ELIGIBLE), and at least
|
|
234
|
+
`required` of them (REF_APPROVAL_INSUFFICIENT). An empty list is
|
|
235
|
+
REF_APPROVAL_INSUFFICIENT. Returns {"accepted", "reason", "grants"}
|
|
236
|
+
with the grants that were counted, so the caller can claim each one."""
|
|
237
|
+
terms = approval_terms(envelope)
|
|
238
|
+
items = as_grant_list(grants)
|
|
239
|
+
seen: List[str] = []
|
|
240
|
+
for grant in items:
|
|
241
|
+
g = verify_grant(
|
|
242
|
+
grant,
|
|
243
|
+
expected_decision_sha256=envelope["decision_sha256"],
|
|
244
|
+
expected_approval_request_id=envelope["approval_request_id"],
|
|
245
|
+
approver_public_keys=approver_public_keys,
|
|
246
|
+
gate_key_id=envelope.get("issuer_key_id"),
|
|
247
|
+
now=now,
|
|
248
|
+
clock_skew=clock_skew,
|
|
249
|
+
)
|
|
250
|
+
if not g["accepted"]:
|
|
251
|
+
return {"accepted": False, "reason": g["reason"], "grants": []}
|
|
252
|
+
key_id = grant["approver_key_id"]
|
|
253
|
+
if key_id in seen:
|
|
254
|
+
return {"accepted": False, "reason": REF_APPROVAL_DUPLICATE_APPROVER, "grants": []}
|
|
255
|
+
if terms["approvers"] is not None and key_id not in terms["approvers"]:
|
|
256
|
+
return {"accepted": False, "reason": REF_APPROVAL_APPROVER_NOT_ELIGIBLE, "grants": []}
|
|
257
|
+
seen.append(key_id)
|
|
258
|
+
if len(seen) < terms["required"]:
|
|
259
|
+
return {"accepted": False, "reason": REF_APPROVAL_INSUFFICIENT, "grants": []}
|
|
260
|
+
return {"accepted": True, "reason": ACCEPT_GRANTS_SUFFICIENT, "grants": list(items)}
|
gargoyle/approver_cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Approver CLI: the human side of an approval hold.
|
|
2
|
+
|
|
3
|
+
A grant only separates duties if a human produces it out of band, with a key
|
|
4
|
+
the issuing service cannot reach. This is that tool, kept minimal: it shows
|
|
5
|
+
the human exactly which held decision they are releasing and, on
|
|
6
|
+
confirmation, emits a signed grant.
|
|
7
|
+
|
|
8
|
+
Run it as a separate process that holds the approver private key; the
|
|
9
|
+
issuing service holds only the approver public key. make_grant() is the
|
|
10
|
+
testable core and main() the thin CLI around it. Grants are built and signed
|
|
11
|
+
by gargoyle.approval, so what this emits is exactly what verify_grant
|
|
12
|
+
accepts.
|
|
13
|
+
|
|
14
|
+
Two ways in. From a file or stdin: the flagged receipt (or a bare hold)
|
|
15
|
+
is shown and, on confirmation, the grant is printed. Against an issuing
|
|
16
|
+
service (--issuer URL, with GARGOYLE_APPROVER_TOKEN for the Bearer): every
|
|
17
|
+
pending flagged receipt is fetched, shown one by one, and each confirmed
|
|
18
|
+
grant is posted back, so the middleware waiting on it proceeds.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
import uuid
|
|
25
|
+
from datetime import datetime, timedelta, timezone
|
|
26
|
+
from typing import Any, Dict, Optional
|
|
27
|
+
|
|
28
|
+
from gargoyle.approval import build_grant, sign_grant
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_grant(
|
|
32
|
+
*,
|
|
33
|
+
approver_private_hex: str,
|
|
34
|
+
approver_key_id: str,
|
|
35
|
+
decision_sha256: str,
|
|
36
|
+
approval_request_id: str,
|
|
37
|
+
not_after_seconds: int = 300,
|
|
38
|
+
grant_id: Optional[str] = None,
|
|
39
|
+
now: Optional[datetime] = None,
|
|
40
|
+
) -> Dict[str, Any]:
|
|
41
|
+
"""Build and sign an approval grant for a held decision. The private key
|
|
42
|
+
is raw Ed25519 bytes in hex, used only here and never returned or logged.
|
|
43
|
+
grant_id defaults to a fresh uuid; it is the grant's single-use key."""
|
|
44
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
45
|
+
|
|
46
|
+
signing_key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(approver_private_hex))
|
|
47
|
+
base = now if now is not None else datetime.now(timezone.utc)
|
|
48
|
+
grant = build_grant(
|
|
49
|
+
decision_sha256=decision_sha256,
|
|
50
|
+
approval_request_id=approval_request_id,
|
|
51
|
+
grant_id=grant_id or uuid.uuid4().hex,
|
|
52
|
+
not_after=base + timedelta(seconds=not_after_seconds),
|
|
53
|
+
)
|
|
54
|
+
return sign_grant(grant, signing_key, approver_key_id)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv=None) -> int:
|
|
58
|
+
"""Read the flagged receipt itself, or a bare hold (JSON with
|
|
59
|
+
approval_request_id and decision_sha256), from --pending or stdin, show
|
|
60
|
+
the human what they are releasing, and on confirmation print the signed
|
|
61
|
+
grant JSON to stdout. A receipt is preferred: it lets the human see the
|
|
62
|
+
tool, the argument hash, the principal and the target they are
|
|
63
|
+
releasing, all of which are inside the issuer's signature.
|
|
64
|
+
|
|
65
|
+
The approver key comes from the environment (GARGOYLE_APPROVER_KEY_HEX +
|
|
66
|
+
GARGOYLE_APPROVER_KEY_ID) so it is never passed on the command line. --yes
|
|
67
|
+
skips the interactive confirm (for scripted approver workflows)."""
|
|
68
|
+
import os
|
|
69
|
+
|
|
70
|
+
parser = argparse.ArgumentParser(
|
|
71
|
+
prog="approver_cli",
|
|
72
|
+
description="Emit a signed approval grant for a held decision.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument("--pending", help="file with the flagged receipt or hold JSON; default stdin")
|
|
75
|
+
parser.add_argument("--issuer", help="issuing service URL: fetch its pending holds and post grants back")
|
|
76
|
+
parser.add_argument("--watch", type=float, metavar="SECONDS",
|
|
77
|
+
help="with --issuer: keep polling for new holds every SECONDS and prompt for each")
|
|
78
|
+
parser.add_argument("--ttl", type=int, default=300, help="grant lifetime (s)")
|
|
79
|
+
parser.add_argument("--yes", action="store_true", help="skip the confirm prompt")
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
|
|
82
|
+
key_hex = os.environ.get("GARGOYLE_APPROVER_KEY_HEX")
|
|
83
|
+
key_id = os.environ.get("GARGOYLE_APPROVER_KEY_ID")
|
|
84
|
+
if not key_hex or not key_id:
|
|
85
|
+
print("GARGOYLE_APPROVER_KEY_HEX and GARGOYLE_APPROVER_KEY_ID must be set",
|
|
86
|
+
file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
|
|
89
|
+
if args.issuer:
|
|
90
|
+
token = os.environ.get("GARGOYLE_APPROVER_TOKEN")
|
|
91
|
+
if not token:
|
|
92
|
+
print("GARGOYLE_APPROVER_TOKEN must be set to talk to the issuer", file=sys.stderr)
|
|
93
|
+
return 2
|
|
94
|
+
if not args.watch:
|
|
95
|
+
return approve_at_issuer(args.issuer, token, key_hex=key_hex, key_id=key_id,
|
|
96
|
+
ttl=args.ttl, yes=args.yes)
|
|
97
|
+
import time
|
|
98
|
+
print("watching %s for holds (Ctrl-C to stop)" % args.issuer, file=sys.stderr)
|
|
99
|
+
try:
|
|
100
|
+
while True:
|
|
101
|
+
approve_at_issuer(args.issuer, token, key_hex=key_hex, key_id=key_id, ttl=args.ttl, yes=args.yes)
|
|
102
|
+
time.sleep(args.watch)
|
|
103
|
+
except KeyboardInterrupt:
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
raw = open(args.pending).read() if args.pending else sys.stdin.read()
|
|
107
|
+
pending = json.loads(raw)
|
|
108
|
+
decision_sha256 = pending["decision_sha256"]
|
|
109
|
+
approval_request_id = pending["approval_request_id"]
|
|
110
|
+
|
|
111
|
+
describe(pending)
|
|
112
|
+
if not args.yes and not confirm():
|
|
113
|
+
print("aborted (no grant emitted)", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
grant = make_grant(
|
|
117
|
+
approver_private_hex=key_hex,
|
|
118
|
+
approver_key_id=key_id,
|
|
119
|
+
decision_sha256=decision_sha256,
|
|
120
|
+
approval_request_id=approval_request_id,
|
|
121
|
+
not_after_seconds=args.ttl,
|
|
122
|
+
)
|
|
123
|
+
print(json.dumps(grant))
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def describe(pending: Dict[str, Any], args: Any = None) -> None:
|
|
128
|
+
"""Show the human exactly what they are releasing: everything inside the
|
|
129
|
+
issuer's signature that identifies the call, and the arguments
|
|
130
|
+
themselves when the issuer supplied them, shown only if they hash to
|
|
131
|
+
the receipt's args_sha256 (otherwise the mismatch is shown instead)."""
|
|
132
|
+
lines = ["About to APPROVE a held high-impact decision:",
|
|
133
|
+
" decision_sha256 : %s" % pending.get("decision_sha256"),
|
|
134
|
+
" approval_request_id : %s" % pending.get("approval_request_id")]
|
|
135
|
+
if isinstance(pending.get("approval"), dict):
|
|
136
|
+
lines.append(" approvals needed : %s%s" % (pending["approval"].get("required"),
|
|
137
|
+
(" from " + ", ".join(pending["approval"]["approvers"])) if pending["approval"].get("approvers") else ""))
|
|
138
|
+
rc = pending.get("request_context")
|
|
139
|
+
if isinstance(rc, dict):
|
|
140
|
+
lines += [" tool : %s" % rc.get("tool"),
|
|
141
|
+
" args_sha256 : %s" % rc.get("args_sha256"),
|
|
142
|
+
" principal : %s" % rc.get("principal")]
|
|
143
|
+
if rc.get("on_behalf_of"):
|
|
144
|
+
lines.append(" on_behalf_of : %s" % rc.get("on_behalf_of"))
|
|
145
|
+
lines += [" target_url : %s" % pending.get("target_url"),
|
|
146
|
+
" policy : %s" % pending.get("policy"),
|
|
147
|
+
" not_after : %s" % pending.get("not_after")]
|
|
148
|
+
if args is not None:
|
|
149
|
+
from gargoyle.envelope import args_sha256
|
|
150
|
+
try:
|
|
151
|
+
bound = args_sha256(args) == rc.get("args_sha256")
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
bound = False
|
|
154
|
+
if bound:
|
|
155
|
+
rendered = json.dumps(args, indent=2, sort_keys=True)
|
|
156
|
+
lines += [" arguments (bound by args_sha256):"] + [" " + l for l in rendered.splitlines()]
|
|
157
|
+
else:
|
|
158
|
+
lines.append(" arguments : DO NOT MATCH args_sha256; refuse this hold")
|
|
159
|
+
sys.stderr.write("\n".join(lines) + "\n")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def confirm() -> bool:
|
|
163
|
+
sys.stderr.write("Type 'approve' to sign a grant: ")
|
|
164
|
+
sys.stderr.flush()
|
|
165
|
+
return sys.stdin.readline().strip() == "approve"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def approve_at_issuer(issuer_url: str, token: str, *, key_hex: str, key_id: str, ttl: int = 300,
|
|
169
|
+
yes: bool = False, http: Any = None) -> int:
|
|
170
|
+
"""Fetch the issuer's pending holds, confirm each, sign and post the
|
|
171
|
+
grant. Exit 0 when every grant posted was accepted, 1 when one was
|
|
172
|
+
declined or refused, 2 when the issuer could not be reached."""
|
|
173
|
+
import requests
|
|
174
|
+
|
|
175
|
+
http = http if http is not None else requests.Session()
|
|
176
|
+
headers = {"Authorization": "Bearer " + token}
|
|
177
|
+
base = issuer_url.rstrip("/")
|
|
178
|
+
try:
|
|
179
|
+
r = http.get(base + "/pending", headers=headers, timeout=10)
|
|
180
|
+
if r.status_code != 200:
|
|
181
|
+
print("issuer refused: %s" % r.text[:200], file=sys.stderr)
|
|
182
|
+
return 2
|
|
183
|
+
pending = r.json().get("pending", [])
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
print("issuer unreachable: %s" % exc, file=sys.stderr)
|
|
186
|
+
return 2
|
|
187
|
+
if not pending:
|
|
188
|
+
return 0
|
|
189
|
+
status = 0
|
|
190
|
+
for item in pending:
|
|
191
|
+
env, args_ = (item["envelope"], item.get("args")) if isinstance(item, dict) and "envelope" in item else (item, None)
|
|
192
|
+
describe(env, args_)
|
|
193
|
+
if args_ is not None:
|
|
194
|
+
from gargoyle.envelope import args_sha256
|
|
195
|
+
try:
|
|
196
|
+
if args_sha256(args_) != env["request_context"]["args_sha256"]:
|
|
197
|
+
print("refusing %s: arguments do not match the receipt" % env["approval_request_id"], file=sys.stderr)
|
|
198
|
+
status = 1
|
|
199
|
+
continue
|
|
200
|
+
except (TypeError, ValueError, KeyError):
|
|
201
|
+
print("refusing %s: arguments unreadable" % env.get("approval_request_id"), file=sys.stderr)
|
|
202
|
+
status = 1
|
|
203
|
+
continue
|
|
204
|
+
if not yes and not confirm():
|
|
205
|
+
print("skipped", file=sys.stderr)
|
|
206
|
+
status = 1
|
|
207
|
+
continue
|
|
208
|
+
grant = make_grant(approver_private_hex=key_hex, approver_key_id=key_id,
|
|
209
|
+
decision_sha256=env["decision_sha256"],
|
|
210
|
+
approval_request_id=env["approval_request_id"], not_after_seconds=ttl)
|
|
211
|
+
try:
|
|
212
|
+
r = http.post(base + "/grants", json=grant, headers=headers, timeout=10)
|
|
213
|
+
body = r.json() if r.status_code == 200 else {"accepted": False, "reason": r.text[:200]}
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
body = {"accepted": False, "reason": str(exc)}
|
|
216
|
+
if body.get("accepted"):
|
|
217
|
+
more = (body.get("required") or 1) - (body.get("collected") or 1)
|
|
218
|
+
print("granted %s%s" % (env["approval_request_id"],
|
|
219
|
+
"" if more <= 0 else " (%d more approver%s needed)" % (more, "" if more == 1 else "s")),
|
|
220
|
+
file=sys.stderr)
|
|
221
|
+
else:
|
|
222
|
+
print("refused %s: %s" % (env["approval_request_id"], body.get("reason")), file=sys.stderr)
|
|
223
|
+
status = 1
|
|
224
|
+
return status
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Which keys may authorize an approval: signed role, not a key-id compare.
|
|
2
|
+
|
|
3
|
+
verify_grant() refuses a grant signed under the issuer's own key id, but a
|
|
4
|
+
key-id compare alone cannot stop an issuer-controlled key published under a
|
|
5
|
+
different id. This module builds the approver trust map from the signed key
|
|
6
|
+
record instead (key_record_source / root_record_source), where every key
|
|
7
|
+
entry carries a `role` covered by the publisher's signature:
|
|
8
|
+
|
|
9
|
+
- Only a key whose signed role is exactly "approver" may authorize an
|
|
10
|
+
approval. An issuer key, or a key with any other or no role, is excluded
|
|
11
|
+
from the map, so a well-signed "approval" minted by the issuer is never
|
|
12
|
+
honored even under a different key id.
|
|
13
|
+
- The key must not be revoked and must be inside its signed validity
|
|
14
|
+
window, widened symmetrically by clock_skew, the same rule
|
|
15
|
+
verify_envelope applies to issuer keys.
|
|
16
|
+
- key_id != gate_key_id is kept as a cheap extra check, not the guarantee.
|
|
17
|
+
|
|
18
|
+
Pure: it consumes a trust view already validated by
|
|
19
|
+
key_record_source.load_key_record_from_bytes (publisher signature,
|
|
20
|
+
freshness, root status) and returns the {key_id: public_key} map
|
|
21
|
+
verify_grant expects. It never trusts a role it did not receive from that
|
|
22
|
+
validated view.
|
|
23
|
+
|
|
24
|
+
Limits. This controls where approver trust comes from. It does not prove the
|
|
25
|
+
issuing process cannot reach the approver's private key; that is a
|
|
26
|
+
deployment property. A key record that publishes no roles yields no approver
|
|
27
|
+
keys, so a deployment using signed approver trust must publish an explicit
|
|
28
|
+
approver role.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from datetime import datetime, timedelta, timezone
|
|
32
|
+
from typing import Any, Dict, Optional
|
|
33
|
+
|
|
34
|
+
# Role tokens from the signed key-record entry's `role` field. APPROVER_ROLE is
|
|
35
|
+
# the only value that makes a key eligible to authorize an approval. ISSUER_ROLE
|
|
36
|
+
# is named for symmetry; like every other value it is not an approver.
|
|
37
|
+
APPROVER_ROLE = "approver"
|
|
38
|
+
ISSUER_ROLE = "issuer"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_approver_keys(
|
|
42
|
+
key_record_trust_view: Any,
|
|
43
|
+
*,
|
|
44
|
+
gate_key_id: Optional[str] = None,
|
|
45
|
+
now: Optional[datetime] = None,
|
|
46
|
+
clock_skew: timedelta = timedelta(0),
|
|
47
|
+
) -> Dict[str, Any]:
|
|
48
|
+
"""
|
|
49
|
+
The {key_id: public_key} map of keys allowed to authorize an approval
|
|
50
|
+
grant, from a validated key-record trust view ({key_id: {public_key, role,
|
|
51
|
+
revoked, not_before, not_after}}). Pass the result to verify_grant() as
|
|
52
|
+
`approver_public_keys`.
|
|
53
|
+
|
|
54
|
+
A key is included only if all of these hold; anything else is left out,
|
|
55
|
+
never raised:
|
|
56
|
+
- its signed role is exactly "approver";
|
|
57
|
+
- it is not revoked;
|
|
58
|
+
- not_before - clock_skew <= now < not_after + clock_skew;
|
|
59
|
+
- key_id != gate_key_id (the issuer's key id).
|
|
60
|
+
|
|
61
|
+
`now` defaults to the current UTC time. A negative clock_skew would narrow
|
|
62
|
+
the window, so it raises ValueError, as key_record_source does. A non-dict
|
|
63
|
+
view, a non-dict entry, or missing key material contributes nothing.
|
|
64
|
+
"""
|
|
65
|
+
if clock_skew < timedelta(0):
|
|
66
|
+
raise ValueError("clock_skew must be non-negative")
|
|
67
|
+
if not isinstance(key_record_trust_view, dict):
|
|
68
|
+
return {}
|
|
69
|
+
if now is None:
|
|
70
|
+
now = datetime.now(timezone.utc)
|
|
71
|
+
|
|
72
|
+
resolved: Dict[str, Any] = {}
|
|
73
|
+
for key_id, info in key_record_trust_view.items():
|
|
74
|
+
if not isinstance(key_id, str) or not isinstance(info, dict):
|
|
75
|
+
continue
|
|
76
|
+
# Cheap extra check; the role check below is the guarantee.
|
|
77
|
+
if gate_key_id is not None and key_id == gate_key_id:
|
|
78
|
+
continue
|
|
79
|
+
# Only a signed "approver" role is eligible. Issuer and role-less keys are
|
|
80
|
+
# excluded, which a key-id compare alone cannot do.
|
|
81
|
+
if info.get("role") != APPROVER_ROLE:
|
|
82
|
+
continue
|
|
83
|
+
if info.get("revoked") is True:
|
|
84
|
+
continue
|
|
85
|
+
public_key = info.get("public_key")
|
|
86
|
+
if public_key is None:
|
|
87
|
+
continue
|
|
88
|
+
not_before = info.get("not_before")
|
|
89
|
+
not_after = info.get("not_after")
|
|
90
|
+
if not isinstance(not_before, datetime) or not isinstance(not_after, datetime):
|
|
91
|
+
continue
|
|
92
|
+
if not (not_before - clock_skew <= now < not_after + clock_skew):
|
|
93
|
+
continue
|
|
94
|
+
resolved[key_id] = public_key
|
|
95
|
+
return resolved
|