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 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)}
@@ -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