gargoyle-receipts 0.18.0.dev0__tar.gz → 0.19.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gargoyle_receipts-0.18.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.19.0.dev0}/PKG-INFO +3 -2
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/README.md +2 -1
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/cli.py +52 -10
- gargoyle_receipts-0.19.0.dev0/gargoyle/emilia.py +438 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/issuer_service.py +8 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/static/approve.html +13 -7
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/static/approve.js +46 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +3 -2
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_console_js.py +32 -0
- gargoyle_receipts-0.19.0.dev0/tests/test_emilia_interop.py +338 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/__main__.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/globs.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/mcp_server.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/reconcile.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_globs.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_operability.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_record_follow.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_review_fixes.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_state_text.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_wire_spec.py +0 -0
- {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_witness.py +0 -0
{gargoyle_receipts-0.18.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.19.0.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.19.0.dev0
|
|
4
4
|
Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
|
|
5
5
|
Author: Justin LaPorte
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
|
|
|
59
59
|
|
|
60
60
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
62
|
+
Status: pre-release (`0.19.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
63
63
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
64
64
|
|
|
65
65
|
## What a receipt gives you
|
|
@@ -289,6 +289,7 @@ hold and deny rules):
|
|
|
289
289
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
290
290
|
gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
|
|
291
291
|
gargoyle tokens --out gargoyle-deploy # later: new client and approver tokens, same keys
|
|
292
|
+
gargoyle emilia --log audit.jsonl # export approved calls as EMILIA Trust Receipts (docs/interop-emilia.md)
|
|
292
293
|
gargoyle issuer # the issuing service, on rules.json
|
|
293
294
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
294
295
|
|
|
@@ -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.19.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
25
25
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
26
26
|
|
|
27
27
|
## What a receipt gives you
|
|
@@ -251,6 +251,7 @@ hold and deny rules):
|
|
|
251
251
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
252
252
|
gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
|
|
253
253
|
gargoyle tokens --out gargoyle-deploy # later: new client and approver tokens, same keys
|
|
254
|
+
gargoyle emilia --log audit.jsonl # export approved calls as EMILIA Trust Receipts (docs/interop-emilia.md)
|
|
254
255
|
gargoyle issuer # the issuing service, on rules.json
|
|
255
256
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
256
257
|
|
|
@@ -490,22 +490,58 @@ def _audit_summary(report, raw) -> None:
|
|
|
490
490
|
print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
|
|
491
491
|
|
|
492
492
|
|
|
493
|
+
def _trust_hex(out: Path) -> "tuple[Dict[str, Any], Dict[str, Any]]":
|
|
494
|
+
"""public-keys.json plus, when present, every key each enrolled approver
|
|
495
|
+
ever held (approvers.json, current first): grants signed under a key
|
|
496
|
+
since replaced in the browser or revoked still have to verify. Only the
|
|
497
|
+
live service and middleware are limited to the current key. Copy the
|
|
498
|
+
issuer's file beside the evidence first. Returns (keys, approvers_hex)."""
|
|
499
|
+
keys = json.loads((out / "public-keys.json").read_text())
|
|
500
|
+
approvers: Dict[str, Any] = dict(keys["approver"])
|
|
501
|
+
enrolled = out / "approvers.json" # approvers who enrolled through Entra, by UPN
|
|
502
|
+
if enrolled.exists():
|
|
503
|
+
from gargoyle.approvers import ApproverRegistry
|
|
504
|
+
approvers.update(ApproverRegistry(str(enrolled)).audit_keys_hex())
|
|
505
|
+
return keys, approvers
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def cmd_emilia(args) -> int:
|
|
509
|
+
"""Export an evidence log's honored, approved calls as EMILIA Trust
|
|
510
|
+
Receipts (docs/interop-emilia.md): one file in the format EMILIA's
|
|
511
|
+
conformance runners read, every receipt carrying the signoff the
|
|
512
|
+
approver's console made, the whole set under one Merkle checkpoint
|
|
513
|
+
signed by this gate's executor key."""
|
|
514
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
515
|
+
from gargoyle.emilia import trust_receipts_from_entries, verification_for
|
|
516
|
+
from gargoyle.evidence import read_log
|
|
517
|
+
out = Path(args.out).resolve()
|
|
518
|
+
keys, approvers = _trust_hex(out)
|
|
519
|
+
env = _read_env(Path(args.env or (out / "middleware.env")))
|
|
520
|
+
if "GARGOYLE_MCP_EXECUTOR_KEY_HEX" not in env:
|
|
521
|
+
print("%s has no GARGOYLE_MCP_EXECUTOR_KEY_HEX; point --env at this gate's middleware.env" % (args.env or out / "middleware.env"), file=sys.stderr)
|
|
522
|
+
return 2
|
|
523
|
+
executor = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(env["GARGOYLE_MCP_EXECUTOR_KEY_HEX"]))
|
|
524
|
+
log_key_id = env.get("GARGOYLE_MCP_EXECUTOR_KEY_ID", "executor-1")
|
|
525
|
+
result = trust_receipts_from_entries(read_log(args.log), log_signing_key=executor, log_key_id=log_key_id)
|
|
526
|
+
vectors = [{"id": r["receipt_id"], "expect": {"valid": True}, "trust_receipt": r,
|
|
527
|
+
"verification": verification_for(r, approvers, executor.public_key())} for r in result["receipts"]]
|
|
528
|
+
missing = [v["id"] for v in vectors if len(v["verification"]["approver_keys"]) < len(v["trust_receipt"]["signoffs"])]
|
|
529
|
+
doc = {"suite": "GARGOYLE EP-TRUST-RECEIPT-v1 export", "source": "gargoyle-receipts", "log": str(Path(args.log).resolve()),
|
|
530
|
+
"exported_at": datetime.now(timezone.utc).isoformat(), "count": len(vectors), "vectors": vectors}
|
|
531
|
+
_write(Path(args.write), json.dumps(doc, indent=1) + "\n")
|
|
532
|
+
print("%d trust receipts under one checkpoint (tree size %d) -> %s; %d approved calls skipped (no console signoff on every grant)%s"
|
|
533
|
+
% (len(vectors), len(vectors), args.write, result["skipped"],
|
|
534
|
+
("; %d receipts name an approver key not in %s/public-keys.json or approvers.json" % (len(missing), out)) if missing else ""))
|
|
535
|
+
return 0
|
|
536
|
+
|
|
537
|
+
|
|
493
538
|
def cmd_audit(args) -> int:
|
|
494
539
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
495
540
|
from gargoyle.execution import verify_chain
|
|
496
541
|
|
|
497
|
-
keys =
|
|
542
|
+
keys, approvers = _trust_hex(Path(args.out).resolve())
|
|
498
543
|
one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
|
|
499
544
|
load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
|
|
500
|
-
approvers: Dict[str, Any] = dict(keys["approver"])
|
|
501
|
-
enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
|
|
502
|
-
if enrolled.exists():
|
|
503
|
-
from gargoyle.approvers import ApproverRegistry
|
|
504
|
-
# Every key each approver ever held, current first: grants signed
|
|
505
|
-
# under a key since replaced in the browser or revoked still have to
|
|
506
|
-
# verify. Only the live service and middleware are limited to the
|
|
507
|
-
# current key. Copy the issuer's file beside the evidence first.
|
|
508
|
-
approvers.update(ApproverRegistry(str(enrolled)).audit_keys_hex())
|
|
509
545
|
trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
|
|
510
546
|
approver_public_keys=load(approvers))
|
|
511
547
|
if args.log:
|
|
@@ -655,6 +691,12 @@ def main(argv=None) -> int:
|
|
|
655
691
|
p.add_argument("--summary", action="store_true", help="after the rows: calls by principal and tool with outcomes, releases, clients")
|
|
656
692
|
p.add_argument("--out", default="gargoyle-deploy")
|
|
657
693
|
p.set_defaults(fn=cmd_audit)
|
|
694
|
+
p = sub.add_parser("emilia", help="export an evidence log's approved calls as EMILIA Trust Receipts")
|
|
695
|
+
p.add_argument("--log", required=True, help="the middleware's audit.jsonl")
|
|
696
|
+
p.add_argument("--write", default="emilia-trust-receipts.json", help="output file, in the format EMILIA's conformance runners read")
|
|
697
|
+
p.add_argument("--env", help="this gate's middleware.env (executor key signs the checkpoint); default <out>/middleware.env")
|
|
698
|
+
p.add_argument("--out", default="gargoyle-deploy")
|
|
699
|
+
p.set_defaults(fn=cmd_emilia)
|
|
658
700
|
args = parser.parse_args(argv)
|
|
659
701
|
return args.fn(args)
|
|
660
702
|
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Interoperation with the EMILIA Protocol (draft-schrock-ep-authorization-
|
|
2
|
+
receipts, https://github.com/emiliaprotocol/emilia-protocol).
|
|
3
|
+
|
|
4
|
+
Two directions, both offline:
|
|
5
|
+
|
|
6
|
+
* verify_ep_receipt: a verifier for EMILIA's generic EP-RECEIPT-v1 envelope
|
|
7
|
+
built on Gargoyle's canonical form (RFC 8785, gargoyle.envelope
|
|
8
|
+
.canonical_json). EMILIA's canonicalizer is byte-identical to it on every
|
|
9
|
+
payload in their published vectors, so the same bytes sign and verify on
|
|
10
|
+
both sides.
|
|
11
|
+
* ep_receipt / trust_receipt: exports of Gargoyle artifacts into EMILIA's two
|
|
12
|
+
profiles. An EP-RECEIPT-v1 envelope wraps any Gargoyle receipt or execution
|
|
13
|
+
receipt under the issuer's or executor's Ed25519 key and verifies with
|
|
14
|
+
@emilia-protocol/verify and emilia-verify as published. A Trust Receipt
|
|
15
|
+
(EP-AUTHORIZATION-RECEIPT-v1) maps a Gargoyle chain onto EMILIA's action,
|
|
16
|
+
contexts, signoffs, consumption and single-leaf log proof.
|
|
17
|
+
|
|
18
|
+
An EMILIA approver signs the SHA-256 of the JCS Authorization Context (32
|
|
19
|
+
bytes, Class B Ed25519); a Gargoyle approver signs the canonical grant. The
|
|
20
|
+
bytes differ, so a stored grant signature is not a signoff. The approval
|
|
21
|
+
console therefore signs both: it builds the Authorization Context for the
|
|
22
|
+
hold it shows (authorization_context, the same bytes this module computes),
|
|
23
|
+
signs its hash with the same browser key, and carries the result inside the
|
|
24
|
+
grant as the optional `emilia` member, which the grant signature then covers.
|
|
25
|
+
The issuer checks the member against the hold before storing the grant
|
|
26
|
+
(verify_embedded_signoff), and `gargoyle emilia` turns an evidence log into
|
|
27
|
+
Trust Receipts (trust_receipts_from_entries) whose signoffs the approver made
|
|
28
|
+
and whose log proof is a Merkle tree over the exported receipts, signed by
|
|
29
|
+
the executor's key."""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import math
|
|
36
|
+
import uuid
|
|
37
|
+
from datetime import datetime
|
|
38
|
+
from typing import Any, Dict, List, Optional
|
|
39
|
+
|
|
40
|
+
from gargoyle.envelope import canonical_json
|
|
41
|
+
|
|
42
|
+
EP_RECEIPT_VERSION = "EP-RECEIPT-v1"
|
|
43
|
+
MERKLE_V2 = "EP-MERKLE-v2"
|
|
44
|
+
CONTEXT_TYPE = "ep.signoff.v1"
|
|
45
|
+
NONCE_PREFIX = "gargoyle-emilia-nonce/1\n"
|
|
46
|
+
EMILIA_MEMBER = "emilia" # the optional grant member: {"context": {...}, "signature": <hex>}
|
|
47
|
+
REF_EMILIA_SIGNOFF_MALFORMED = "REF_EMILIA_SIGNOFF_MALFORMED"
|
|
48
|
+
REF_EMILIA_CONTEXT_MISMATCH = "REF_EMILIA_CONTEXT_MISMATCH"
|
|
49
|
+
REF_EMILIA_SIGNATURE_INVALID = "REF_EMILIA_SIGNATURE_INVALID"
|
|
50
|
+
_SAFE_INT = 2 ** 53 - 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def b64u(data: bytes) -> str:
|
|
54
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def b64u_decode(text: str) -> bytes:
|
|
58
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def spki_b64u(public_key: Any) -> str:
|
|
62
|
+
"""EMILIA pins keys as base64url SubjectPublicKeyInfo DER."""
|
|
63
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
64
|
+
return b64u(public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def in_profile(value: Any) -> bool:
|
|
68
|
+
"""EMILIA's I-JSON signing profile: strings, bools, null, safe integers.
|
|
69
|
+
Gargoyle artifacts are inside it (hashes and timestamps are strings)."""
|
|
70
|
+
if value is None or isinstance(value, (str, bool)):
|
|
71
|
+
return True
|
|
72
|
+
if isinstance(value, float):
|
|
73
|
+
return math.isfinite(value) and value.is_integer() and abs(value) <= _SAFE_INT
|
|
74
|
+
if isinstance(value, int):
|
|
75
|
+
return -_SAFE_INT <= value <= _SAFE_INT
|
|
76
|
+
if isinstance(value, list):
|
|
77
|
+
return all(in_profile(v) for v in value)
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
return all(isinstance(k, str) and in_profile(v) for k, v in value.items())
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _sha256_hex(text: str) -> str:
|
|
84
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _hex_of(value: Any) -> str:
|
|
88
|
+
if not isinstance(value, str):
|
|
89
|
+
return ""
|
|
90
|
+
value = value[7:] if value.startswith("sha256:") else value
|
|
91
|
+
value = value.lower()
|
|
92
|
+
return value if len(value) == 64 and all(c in "0123456789abcdef" for c in value) else ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _leaf_v2(canonical: str) -> str:
|
|
96
|
+
return hashlib.sha256(b"\x00" + canonical.encode("utf-8")).hexdigest()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _pair_v2(left: str, right: str) -> str:
|
|
100
|
+
return hashlib.sha256(b"\x01" + left.encode("utf-8") + right.encode("utf-8")).hexdigest()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- EMILIA -> Gargoyle: verify an EP-RECEIPT-v1 envelope ---------------------
|
|
104
|
+
|
|
105
|
+
def verify_ep_receipt(doc: Any, public_key_spki_b64u: str) -> Dict[str, Any]:
|
|
106
|
+
"""Version, Ed25519 over the canonical payload, and the EP-MERKLE-v2 anchor
|
|
107
|
+
when present (a legacy v1 anchor is refused, as EMILIA's verifiers do by
|
|
108
|
+
default). Never raises on bad input."""
|
|
109
|
+
from cryptography.exceptions import InvalidSignature
|
|
110
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
111
|
+
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
|
112
|
+
checks: Dict[str, Any] = {"version": False, "signature": False, "anchor": None}
|
|
113
|
+
|
|
114
|
+
def refuse(reason: str) -> Dict[str, Any]:
|
|
115
|
+
return {"valid": False, "checks": checks, "error": reason}
|
|
116
|
+
|
|
117
|
+
if not isinstance(doc, dict) or doc.get("@version") != EP_RECEIPT_VERSION:
|
|
118
|
+
return refuse("unsupported version")
|
|
119
|
+
checks["version"] = True
|
|
120
|
+
payload, sig = doc.get("payload"), doc.get("signature")
|
|
121
|
+
if not isinstance(payload, dict) or not payload or not isinstance(sig, dict):
|
|
122
|
+
return refuse("payload and signature must be objects")
|
|
123
|
+
if not isinstance(sig.get("algorithm"), str) or sig["algorithm"].lower() != "ed25519" or not sig.get("value"):
|
|
124
|
+
return refuse("EP-RECEIPT-v1 requires Ed25519")
|
|
125
|
+
if not in_profile(payload):
|
|
126
|
+
return refuse("payload outside the EMILIA canonicalization profile")
|
|
127
|
+
canonical = canonical_json(payload)
|
|
128
|
+
try:
|
|
129
|
+
pub = load_der_public_key(b64u_decode(public_key_spki_b64u))
|
|
130
|
+
if not isinstance(pub, Ed25519PublicKey):
|
|
131
|
+
return refuse("public key is not Ed25519")
|
|
132
|
+
pub.verify(b64u_decode(str(sig["value"])), canonical.encode("utf-8"))
|
|
133
|
+
checks["signature"] = True
|
|
134
|
+
except InvalidSignature:
|
|
135
|
+
return refuse("signature does not verify")
|
|
136
|
+
except Exception as exc: # malformed key or signature encoding
|
|
137
|
+
return refuse("signature verification failed: %s" % exc)
|
|
138
|
+
anchor = doc.get("anchor")
|
|
139
|
+
if anchor is None:
|
|
140
|
+
return {"valid": True, "checks": checks, "error": None}
|
|
141
|
+
if not isinstance(anchor, dict):
|
|
142
|
+
checks["anchor"] = False
|
|
143
|
+
return refuse("anchor must be an object")
|
|
144
|
+
if isinstance(anchor.get("merkle_proof"), list) and isinstance(anchor.get("leaf_hash"), str) and isinstance(anchor.get("merkle_root"), str):
|
|
145
|
+
ok = anchor.get("alg") == MERKLE_V2 and len(anchor["merkle_proof"]) <= 20
|
|
146
|
+
current = anchor["leaf_hash"]
|
|
147
|
+
ok = ok and current == _leaf_v2(canonical)
|
|
148
|
+
for step in anchor["merkle_proof"] if ok else []:
|
|
149
|
+
if not isinstance(step, dict) or step.get("position") not in ("left", "right") or not isinstance(step.get("hash"), str):
|
|
150
|
+
ok = False
|
|
151
|
+
break
|
|
152
|
+
current = _pair_v2(step["hash"], current) if step["position"] == "left" else _pair_v2(current, step["hash"])
|
|
153
|
+
checks["anchor"] = ok and current == anchor["merkle_root"]
|
|
154
|
+
if not checks["anchor"]:
|
|
155
|
+
return refuse("anchor does not bind this payload to the root")
|
|
156
|
+
return {"valid": True, "checks": checks, "error": None}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- Gargoyle -> EMILIA: exports ---------------------------------------------
|
|
160
|
+
|
|
161
|
+
def ep_receipt(payload: Dict[str, Any], signing_key: Any) -> Dict[str, Any]:
|
|
162
|
+
"""Wrap a Gargoyle receipt (or execution receipt, grant, evidence entry) in
|
|
163
|
+
an EP-RECEIPT-v1 envelope signed by `signing_key` (Ed25519, .sign)."""
|
|
164
|
+
if not isinstance(payload, dict) or not payload or not in_profile(payload):
|
|
165
|
+
raise ValueError("payload must be a non-empty object inside the EMILIA profile")
|
|
166
|
+
signature = signing_key.sign(canonical_json(payload).encode("utf-8"))
|
|
167
|
+
return {"@version": EP_RECEIPT_VERSION, "payload": payload, "signature": {"algorithm": "Ed25519", "value": b64u(signature)}}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def action_object(receipt: Dict[str, Any]) -> Dict[str, Any]:
|
|
171
|
+
"""EMILIA Action Object for a Gargoyle receipt: the bound call, the target,
|
|
172
|
+
the principal, the policy id, and a back-reference to the native
|
|
173
|
+
decision so the two artifacts name one action."""
|
|
174
|
+
rc = receipt["request_context"]
|
|
175
|
+
params: Dict[str, Any] = {"args_sha256": rc["args_sha256"]}
|
|
176
|
+
if rc.get("state_sha256"):
|
|
177
|
+
params["state_sha256"] = rc["state_sha256"]
|
|
178
|
+
action = {
|
|
179
|
+
"ep_version": "1.0",
|
|
180
|
+
"action_type": "mcp.tools/call",
|
|
181
|
+
"target": {"system": receipt["target_url"], "resource": rc["tool"]},
|
|
182
|
+
"parameters": params,
|
|
183
|
+
"initiator": rc["principal"],
|
|
184
|
+
"policy_id": receipt["policy"]["policy_id"],
|
|
185
|
+
"requested_at": receipt["timestamp_utc"],
|
|
186
|
+
"gargoyle": {"envelope_version": receipt["envelope_version"], "decision_sha256": receipt["decision_sha256"],
|
|
187
|
+
"decision_id": receipt["decision_id"]},
|
|
188
|
+
}
|
|
189
|
+
if rc.get("on_behalf_of"):
|
|
190
|
+
action["on_behalf_of"] = rc["on_behalf_of"]
|
|
191
|
+
return action
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def action_hash(action: Dict[str, Any]) -> str:
|
|
195
|
+
return "sha256:" + _sha256_hex(canonical_json(action))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def authorization_instance(receipt: Dict[str, Any]) -> str:
|
|
199
|
+
"""The per-ceremony value shared by every context: Gargoyle's decision_id
|
|
200
|
+
(a v4 UUID, 122 random bits) as base64url bytes."""
|
|
201
|
+
return b64u(uuid.UUID(receipt["decision_id"]).bytes)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def context_nonce(receipt: Dict[str, Any], approver_id: str) -> str:
|
|
205
|
+
"""Per-context nonce: 128 bits derived from the random approval_request_id
|
|
206
|
+
and the approver's id, so two approvers of one hold never share a nonce
|
|
207
|
+
whatever order they sign in, and console and issuer compute the same."""
|
|
208
|
+
digest = hashlib.sha256((NONCE_PREFIX + receipt["approval_request_id"] + "\n" + approver_id).encode("utf-8")).digest()
|
|
209
|
+
return b64u(digest[:16])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def authorization_context(receipt: Dict[str, Any], approver_id: str, index: int, *, expires_at: Optional[str] = None,
|
|
213
|
+
action: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
214
|
+
"""The Authorization Context an approver signs the hash of. policy_hash is
|
|
215
|
+
Gargoyle's policy pin, so EMILIA's pin check and Gargoyle's are one."""
|
|
216
|
+
action = action or action_object(receipt)
|
|
217
|
+
terms = receipt.get("approval") or {}
|
|
218
|
+
return {
|
|
219
|
+
"ep_version": "1.0",
|
|
220
|
+
"context_type": CONTEXT_TYPE,
|
|
221
|
+
"action_hash": action_hash(action),
|
|
222
|
+
"policy_id": receipt["policy"]["policy_id"],
|
|
223
|
+
"policy_hash": "sha256:" + receipt["policy"]["policy_sha256"],
|
|
224
|
+
"initiator": receipt["request_context"]["principal"],
|
|
225
|
+
"authorization_instance": authorization_instance(receipt),
|
|
226
|
+
"audience": receipt["target_url"],
|
|
227
|
+
"approver": approver_id,
|
|
228
|
+
"approver_index": index,
|
|
229
|
+
"required_approvals": int(terms.get("required", 1)),
|
|
230
|
+
"nonce": context_nonce(receipt, approver_id),
|
|
231
|
+
"issued_at": receipt["timestamp_utc"],
|
|
232
|
+
"expires_at": expires_at or receipt["not_after"],
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def context_hash_for(context: Dict[str, Any]) -> bytes:
|
|
237
|
+
"""The 32 bytes an EMILIA approver signs (Class B: raw Ed25519 over them)."""
|
|
238
|
+
return hashlib.sha256(canonical_json(context).encode("utf-8")).digest()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def signoff(context: Dict[str, Any], signature: bytes, approver_key_id: str, signed_at: str, key_class: str = "B") -> Dict[str, Any]:
|
|
242
|
+
return {"context_hash": "sha256:" + context_hash_for(context).hex(), "signature": b64u(signature),
|
|
243
|
+
"key_class": key_class, "approver_key_id": approver_key_id, "signed_at": signed_at}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def trust_receipt(receipt: Dict[str, Any], contexts: List[Dict[str, Any]], signoffs: List[Dict[str, Any]],
|
|
247
|
+
execution: Dict[str, Any], log_signing_key: Any, log_key_id: str) -> Dict[str, Any]:
|
|
248
|
+
"""An EP-AUTHORIZATION-RECEIPT-v1 Trust Receipt for one honored call. The
|
|
249
|
+
log proof is a single-leaf EP-MERKLE-v2 tree whose checkpoint the given
|
|
250
|
+
key signs (the issuer's key serves as EMILIA's log key); EMILIA's
|
|
251
|
+
degenerate empty-path rule accepts exactly this shape. committed_at is
|
|
252
|
+
the execution receipt's executed_at."""
|
|
253
|
+
action = action_object(receipt)
|
|
254
|
+
body: Dict[str, Any] = {
|
|
255
|
+
"receipt_id": "ep:receipt:gargoyle:" + receipt["decision_id"],
|
|
256
|
+
"action": action,
|
|
257
|
+
"action_hash": action_hash(action),
|
|
258
|
+
"contexts": [dict(c) for c in contexts],
|
|
259
|
+
"signoffs": [dict(s) for s in signoffs],
|
|
260
|
+
"consumption": {"nonce": authorization_instance(receipt), "state": "COMMITTED", "committed_at": execution["executed_at"]},
|
|
261
|
+
}
|
|
262
|
+
leaf = _leaf_v2(canonical_json(body))
|
|
263
|
+
checkpoint = {"tree_size": 1, "root_hash": "sha256:" + leaf, "log_key_id": log_key_id, "merkle_alg": MERKLE_V2}
|
|
264
|
+
checkpoint["log_signature"] = b64u(log_signing_key.sign(hashlib.sha256(canonical_json(checkpoint).encode("utf-8")).digest()))
|
|
265
|
+
body["log_proof"] = {"alg": MERKLE_V2, "leaf_hash": "sha256:" + leaf, "leaf_index": 0, "inclusion_path": [], "checkpoint": checkpoint}
|
|
266
|
+
body["approver_key_proofs"] = []
|
|
267
|
+
return body
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def approver_key_entry(approver_id: str, public_key: Any, *, valid_from: str, valid_to: str, key_class: str = "B") -> Dict[str, Any]:
|
|
271
|
+
"""The pinned directory entry EMILIA's verifier takes for one approver."""
|
|
272
|
+
return {"approver_id": approver_id, "public_key": spki_b64u(public_key), "key_class": key_class,
|
|
273
|
+
"valid_from": valid_from, "valid_to": valid_to}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# --- the grant's embedded signoff -------------------------------------------
|
|
277
|
+
|
|
278
|
+
def _parse_instant(text: Any) -> Optional[datetime]:
|
|
279
|
+
if not isinstance(text, str):
|
|
280
|
+
return None
|
|
281
|
+
try:
|
|
282
|
+
value = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
283
|
+
except ValueError:
|
|
284
|
+
return None
|
|
285
|
+
return value if value.tzinfo is not None else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def verify_embedded_signoff(grant: Dict[str, Any], receipt: Dict[str, Any], public_keys: Any) -> Dict[str, Any]:
|
|
289
|
+
"""Check the grant's `emilia` member against the hold it releases: the
|
|
290
|
+
context must be exactly the one this module derives from the receipt
|
|
291
|
+
for the grant's approver (only approver_index and expires_at are the
|
|
292
|
+
console's to choose, and expires_at may not outlive the receipt or the
|
|
293
|
+
grant), and the signature must verify over the context hash under one
|
|
294
|
+
of the approver's keys. Returns {"accepted", "reason"}."""
|
|
295
|
+
from cryptography.exceptions import InvalidSignature
|
|
296
|
+
member = grant.get(EMILIA_MEMBER)
|
|
297
|
+
if not isinstance(member, dict) or not isinstance(member.get("context"), dict) or not isinstance(member.get("signature"), str):
|
|
298
|
+
return {"accepted": False, "reason": REF_EMILIA_SIGNOFF_MALFORMED}
|
|
299
|
+
ctx = member["context"]
|
|
300
|
+
index, expires_at = ctx.get("approver_index"), ctx.get("expires_at")
|
|
301
|
+
if isinstance(index, bool) or not isinstance(index, int) or index < 1:
|
|
302
|
+
return {"accepted": False, "reason": REF_EMILIA_SIGNOFF_MALFORMED}
|
|
303
|
+
exp = _parse_instant(expires_at)
|
|
304
|
+
if exp is None:
|
|
305
|
+
return {"accepted": False, "reason": REF_EMILIA_SIGNOFF_MALFORMED}
|
|
306
|
+
for bound in (receipt.get("not_after"), grant.get("not_after")):
|
|
307
|
+
limit = _parse_instant(bound)
|
|
308
|
+
if limit is None or exp > limit:
|
|
309
|
+
return {"accepted": False, "reason": REF_EMILIA_CONTEXT_MISMATCH}
|
|
310
|
+
try:
|
|
311
|
+
expected = authorization_context(receipt, grant["approver_key_id"], index, expires_at=expires_at)
|
|
312
|
+
except (KeyError, TypeError, ValueError):
|
|
313
|
+
return {"accepted": False, "reason": REF_EMILIA_CONTEXT_MISMATCH}
|
|
314
|
+
if ctx != expected:
|
|
315
|
+
return {"accepted": False, "reason": REF_EMILIA_CONTEXT_MISMATCH}
|
|
316
|
+
try:
|
|
317
|
+
signature = bytes.fromhex(member["signature"])
|
|
318
|
+
except ValueError:
|
|
319
|
+
return {"accepted": False, "reason": REF_EMILIA_SIGNATURE_INVALID}
|
|
320
|
+
digest = context_hash_for(ctx)
|
|
321
|
+
keys = list(public_keys) if isinstance(public_keys, (list, tuple)) else [public_keys]
|
|
322
|
+
for key in keys:
|
|
323
|
+
if key is None:
|
|
324
|
+
continue
|
|
325
|
+
try:
|
|
326
|
+
key.verify(signature, digest)
|
|
327
|
+
return {"accepted": True, "reason": None}
|
|
328
|
+
except InvalidSignature:
|
|
329
|
+
continue
|
|
330
|
+
except Exception:
|
|
331
|
+
continue
|
|
332
|
+
return {"accepted": False, "reason": REF_EMILIA_SIGNATURE_INVALID}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def signoff_from_grant(grant: Dict[str, Any], signed_at: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
336
|
+
"""The EMILIA signoff a grant carries, or None when the approver's
|
|
337
|
+
console did not sign one (an approver CLI, or a console older than
|
|
338
|
+
this member). signed_at is not in the grant; the caller passes the
|
|
339
|
+
evidence entry's time, which lies inside the context window."""
|
|
340
|
+
member = grant.get(EMILIA_MEMBER) if isinstance(grant, dict) else None
|
|
341
|
+
if not isinstance(member, dict) or not isinstance(member.get("context"), dict):
|
|
342
|
+
return None
|
|
343
|
+
return signoff(member["context"], bytes.fromhex(member["signature"]), grant["approver_key_id"], signed_at or member["context"]["issued_at"])
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# --- Merkle tree over exported receipts (EP-MERKLE-v2) ------------------------
|
|
347
|
+
|
|
348
|
+
def merkle_tree(leaves: List[str]) -> "tuple[str, List[List[Dict[str, str]]]]":
|
|
349
|
+
"""Root and one inclusion path per leaf (hex leaf hashes in), with
|
|
350
|
+
EMILIA's positional pair rule: an odd node is promoted unchanged, so
|
|
351
|
+
every path folds back to the root under their verify_merkle_anchor."""
|
|
352
|
+
if not leaves:
|
|
353
|
+
raise ValueError("no leaves")
|
|
354
|
+
paths: List[List[Dict[str, str]]] = [[] for _ in leaves]
|
|
355
|
+
level = [(h, [i]) for i, h in enumerate(leaves)] # (hash, leaf indexes under it)
|
|
356
|
+
while len(level) > 1:
|
|
357
|
+
nxt = []
|
|
358
|
+
for i in range(0, len(level), 2):
|
|
359
|
+
if i + 1 == len(level):
|
|
360
|
+
nxt.append(level[i])
|
|
361
|
+
continue
|
|
362
|
+
(lh, li), (rh, ri) = level[i], level[i + 1]
|
|
363
|
+
for leaf in li:
|
|
364
|
+
paths[leaf].append({"hash": rh, "position": "right"})
|
|
365
|
+
for leaf in ri:
|
|
366
|
+
paths[leaf].append({"hash": lh, "position": "left"})
|
|
367
|
+
nxt.append((_pair_v2(lh, rh), li + ri))
|
|
368
|
+
level = nxt
|
|
369
|
+
return level[0][0], paths
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def trust_receipts_from_entries(entries: List[Any], *, log_signing_key: Any, log_key_id: str) -> Dict[str, Any]:
|
|
373
|
+
"""Every honored, flagged evidence entry whose grants all carry an EMILIA
|
|
374
|
+
signoff becomes a Trust Receipt; the receipts form one Merkle tree whose
|
|
375
|
+
checkpoint `log_signing_key` signs. Returns {"receipts": [...],
|
|
376
|
+
"checkpoint": {...}, "skipped": n} where skipped counts honored flagged
|
|
377
|
+
entries without a signoff on every grant."""
|
|
378
|
+
from gargoyle.approval import as_grant_list
|
|
379
|
+
bodies: List[Dict[str, Any]] = []
|
|
380
|
+
skipped = 0
|
|
381
|
+
for entry in entries:
|
|
382
|
+
if not isinstance(entry, dict) or entry.get("kind") != "honored":
|
|
383
|
+
continue
|
|
384
|
+
receipt, execution = entry.get("receipt"), entry.get("execution")
|
|
385
|
+
if not isinstance(receipt, dict) or not receipt.get("requires_approval") or not isinstance(execution, dict):
|
|
386
|
+
continue
|
|
387
|
+
grants = [g for g in as_grant_list(entry.get("grant")) if isinstance(g, dict)]
|
|
388
|
+
signoffs = [signoff_from_grant(g, entry.get("at")) for g in grants]
|
|
389
|
+
if not grants or any(s is None for s in signoffs):
|
|
390
|
+
skipped += 1
|
|
391
|
+
continue
|
|
392
|
+
contexts = [g[EMILIA_MEMBER]["context"] for g in grants]
|
|
393
|
+
action = action_object(receipt)
|
|
394
|
+
bodies.append({
|
|
395
|
+
"receipt_id": "ep:receipt:gargoyle:" + receipt["decision_id"],
|
|
396
|
+
"action": action,
|
|
397
|
+
"action_hash": action_hash(action),
|
|
398
|
+
"contexts": contexts,
|
|
399
|
+
"signoffs": signoffs,
|
|
400
|
+
"consumption": {"nonce": authorization_instance(receipt), "state": "COMMITTED", "committed_at": execution["executed_at"]},
|
|
401
|
+
})
|
|
402
|
+
if not bodies:
|
|
403
|
+
return {"receipts": [], "checkpoint": None, "skipped": skipped}
|
|
404
|
+
leaves = [_leaf_v2(canonical_json(b)) for b in bodies]
|
|
405
|
+
root, paths = merkle_tree(leaves)
|
|
406
|
+
checkpoint = {"tree_size": len(leaves), "root_hash": "sha256:" + root, "log_key_id": log_key_id, "merkle_alg": MERKLE_V2}
|
|
407
|
+
checkpoint["log_signature"] = b64u(log_signing_key.sign(hashlib.sha256(canonical_json(checkpoint).encode("utf-8")).digest()))
|
|
408
|
+
receipts = []
|
|
409
|
+
for i, body in enumerate(bodies):
|
|
410
|
+
out = dict(body)
|
|
411
|
+
out["log_proof"] = {"alg": MERKLE_V2, "leaf_hash": "sha256:" + leaves[i], "leaf_index": i, "inclusion_path": paths[i], "checkpoint": checkpoint}
|
|
412
|
+
out["approver_key_proofs"] = []
|
|
413
|
+
receipts.append(out)
|
|
414
|
+
return {"receipts": receipts, "checkpoint": checkpoint, "skipped": skipped}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def verification_for(trust_receipt: Dict[str, Any], approver_keys_hex: Dict[str, Any], log_public_key: Any) -> Dict[str, Any]:
|
|
418
|
+
"""EMILIA's pinned trust inputs for one exported receipt: for each
|
|
419
|
+
signoff, the approver key (among the id's current and past keys) that
|
|
420
|
+
verifies it, and the log key."""
|
|
421
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
422
|
+
entries: Dict[str, Any] = {}
|
|
423
|
+
for so in trust_receipt["signoffs"]:
|
|
424
|
+
kid = so["approver_key_id"]
|
|
425
|
+
candidates = approver_keys_hex.get(kid)
|
|
426
|
+
candidates = list(candidates) if isinstance(candidates, (list, tuple)) else [candidates]
|
|
427
|
+
digest = bytes.fromhex(so["context_hash"][7:])
|
|
428
|
+
for hexkey in candidates:
|
|
429
|
+
if not isinstance(hexkey, str):
|
|
430
|
+
continue
|
|
431
|
+
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(hexkey))
|
|
432
|
+
try:
|
|
433
|
+
pub.verify(b64u_decode(so["signature"]), digest)
|
|
434
|
+
except Exception:
|
|
435
|
+
continue
|
|
436
|
+
entries[kid] = {"approver_id": kid, "public_key": spki_b64u(pub), "key_class": "B"}
|
|
437
|
+
break
|
|
438
|
+
return {"approver_keys": entries, "log_public_key": spki_b64u(log_public_key)}
|
|
@@ -53,6 +53,7 @@ STATIC = Path(__file__).resolve().parent / "static"
|
|
|
53
53
|
from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_DUPLICATE_APPROVER,
|
|
54
54
|
verify_grant)
|
|
55
55
|
from gargoyle.envelope import approval_terms
|
|
56
|
+
from gargoyle.emilia import EMILIA_MEMBER, verify_embedded_signoff
|
|
56
57
|
from gargoyle.state import state_sha256, text_consistent, valid_description
|
|
57
58
|
from gargoyle.holdstore import ENV_HOLDS_PATH, HoldStore
|
|
58
59
|
from gargoyle.witness import WitnessLog
|
|
@@ -535,6 +536,13 @@ def build_issuer_app(
|
|
|
535
536
|
return refused(REF_APPROVAL_DUPLICATE_APPROVER)
|
|
536
537
|
if terms["approvers"] is not None and grant["approver_key_id"] not in terms["approvers"]:
|
|
537
538
|
return refused(REF_APPROVAL_APPROVER_NOT_ELIGIBLE)
|
|
539
|
+
if env is not None and EMILIA_MEMBER in grant:
|
|
540
|
+
# The console's second signature, over the EMILIA Authorization
|
|
541
|
+
# Context for this hold: checked against the hold now so that an
|
|
542
|
+
# export later carries only signoffs the approver actually made.
|
|
543
|
+
s = verify_embedded_signoff(grant, env, trusted.get(grant["approver_key_id"]))
|
|
544
|
+
if not s["accepted"]:
|
|
545
|
+
return refused(s["reason"])
|
|
538
546
|
have.append(grant)
|
|
539
547
|
app.state.grants[arid] = have # written back: the store persists on assignment
|
|
540
548
|
event("granted", approval_request_id=arid, approver=grant["approver_key_id"], collected=len(have), required=terms["required"],
|
{gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/static/approve.html
RENAMED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
<div class="log" id="log"></div>
|
|
53
53
|
</main>
|
|
54
54
|
<script type="module">
|
|
55
|
-
import { argsSha256, sha256Hex, canonicalJson, authorizeUrl, buildGrant, exchangeCode, generateApproverKey, importApproverKey, isoUtc, lineDiff, loadStoredKey,
|
|
55
|
+
import { argsSha256, sha256Hex, canonicalJson, authorizeUrl, buildGrant, earlier, emiliaContext, emiliaSignoff, exchangeCode, generateApproverKey, importApproverKey, isoUtc, lineDiff, loadStoredKey,
|
|
56
56
|
newGrantId, pkceChallenge, publicKeyHex, randomVerifier, signGrant, storeKey } from "./approve.js";
|
|
57
57
|
|
|
58
58
|
const state = { token: null, key: null, keyId: null, ttl: 300, timer: null, seen: new Map() };
|
|
@@ -158,11 +158,12 @@ async function refresh() {
|
|
|
158
158
|
const progress = required > 1 ? `${collected} of ${required} approvals` + (signers.length ? ` (${signers.map(esc).join(", ")})` : "") : "";
|
|
159
159
|
if (state.seen.has(id)) {
|
|
160
160
|
const card = state.seen.get(id);
|
|
161
|
+
card.dataset.collected = collected;
|
|
161
162
|
const p = card.querySelector(".progress"); if (p) p.textContent = progress;
|
|
162
163
|
if (mine) { card.querySelector(".approve").disabled = true; card.querySelector(".result").innerHTML = '<span class="ok">you signed; waiting for the others</span>'; }
|
|
163
164
|
continue;
|
|
164
165
|
}
|
|
165
|
-
const card = document.createElement("article"); card.className = "hold"; card.dataset.id = id;
|
|
166
|
+
const card = document.createElement("article"); card.className = "hold"; card.dataset.id = id; card.dataset.collected = collected;
|
|
166
167
|
const rc = env.request_context;
|
|
167
168
|
const bound = item.args !== null && item.args !== undefined ? (await argsSha256(item.args)) === rc.args_sha256 : false;
|
|
168
169
|
const st = await verifyState(item.state, rc);
|
|
@@ -229,11 +230,16 @@ function renderPlan(item, ids) {
|
|
|
229
230
|
async function approve(card, env) {
|
|
230
231
|
const buttons = card.querySelectorAll("button"); buttons.forEach((b) => b.disabled = true);
|
|
231
232
|
try {
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
const notAfter = isoUtc(new Date(Date.now() + state.ttl * 1000));
|
|
234
|
+
const grant = buildGrant({ decisionSha256: env.decision_sha256, approvalRequestId: env.approval_request_id, grantId: newGrantId(), notAfter });
|
|
235
|
+
if (env.envelope_version) {
|
|
236
|
+
// A receipt hold (not a plan): also sign the EMILIA Authorization Context
|
|
237
|
+
// for it, so an export of this approval verifies under EMILIA's verifiers.
|
|
238
|
+
const index = 1 + (parseInt(card.dataset.collected || "0", 10) || 0);
|
|
239
|
+
grant.emilia = await emiliaSignoff(await emiliaContext(env, state.keyId, index, earlier(env.not_after, notAfter)), state.key);
|
|
240
|
+
}
|
|
241
|
+
const signed = await signGrant(grant, state.key, state.keyId);
|
|
242
|
+
const res = await api("grants", { method: "POST", body: JSON.stringify(signed) });
|
|
237
243
|
if (res.accepted && res.complete === false) {
|
|
238
244
|
// A threshold hold: this signature is in, the call waits for the others.
|
|
239
245
|
card.querySelector(".result").innerHTML = `<span class="ok">you signed; ${res.required - res.collected} more needed</span>`;
|