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.
Files changed (92) hide show
  1. {gargoyle_receipts-0.18.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.19.0.dev0}/PKG-INFO +3 -2
  2. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/README.md +2 -1
  3. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/cli.py +52 -10
  4. gargoyle_receipts-0.19.0.dev0/gargoyle/emilia.py +438 -0
  5. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/issuer_service.py +8 -0
  6. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/static/approve.html +13 -7
  7. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/static/approve.js +46 -0
  8. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +3 -2
  9. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
  10. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/pyproject.toml +1 -1
  11. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_console_js.py +32 -0
  12. gargoyle_receipts-0.19.0.dev0/tests/test_emilia_interop.py +338 -0
  13. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/LICENSE +0 -0
  14. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/NOTICE +0 -0
  15. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/__init__.py +0 -0
  16. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/__main__.py +0 -0
  17. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/adapters.py +0 -0
  18. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approval.py +0 -0
  19. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approver_cli.py +0 -0
  20. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approver_trust.py +0 -0
  21. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approvers.py +0 -0
  22. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/consumer.py +0 -0
  23. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/entra_login.py +0 -0
  24. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/envelope.py +0 -0
  25. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/evidence.py +0 -0
  26. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/execution.py +0 -0
  27. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/fs_server.py +0 -0
  28. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/globs.py +0 -0
  29. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/holdstore.py +0 -0
  30. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/identity.py +0 -0
  31. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/issuer.py +0 -0
  32. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/jws.py +0 -0
  33. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/key_record_source.py +0 -0
  34. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/mcp_server.py +0 -0
  35. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/notify.py +0 -0
  36. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/opa.py +0 -0
  37. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/pending_store.py +0 -0
  38. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/plans.py +0 -0
  39. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/policy.py +0 -0
  40. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/published_record_source.py +0 -0
  41. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/reconcile.py +0 -0
  42. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/remote.py +0 -0
  43. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/replay_cache.py +0 -0
  44. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/revert.py +0 -0
  45. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/rules.py +0 -0
  46. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  47. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/state.py +0 -0
  48. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/verifier.py +0 -0
  49. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/witness.py +0 -0
  50. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  51. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  52. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  53. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  54. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/setup.cfg +0 -0
  55. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_annotations.py +0 -0
  56. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_approver_cli.py +0 -0
  57. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_approvers.py +0 -0
  58. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_canonical.py +0 -0
  59. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_cli.py +0 -0
  60. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_consumer.py +0 -0
  61. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_entra_identity.py +0 -0
  62. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_entra_login.py +0 -0
  63. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_evidence.py +0 -0
  64. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_execution.py +0 -0
  65. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_fs_server.py +0 -0
  66. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_globs.py +0 -0
  67. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_graph.py +0 -0
  68. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_historical.py +0 -0
  69. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_identity.py +0 -0
  70. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_issuer_service.py +0 -0
  71. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_jws.py +0 -0
  72. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_mcp_proxy.py +0 -0
  73. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  74. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_mcp_server.py +0 -0
  75. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_notify.py +0 -0
  76. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_opa.py +0 -0
  77. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_operability.py +0 -0
  78. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_plans.py +0 -0
  79. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_policy.py +0 -0
  80. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_published_record.py +0 -0
  81. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_record_follow.py +0 -0
  82. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_remote.py +0 -0
  83. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_revert.py +0 -0
  84. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_review_fixes.py +0 -0
  85. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_rules.py +0 -0
  86. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_state.py +0 -0
  87. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_state_text.py +0 -0
  88. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_stdio_encoding.py +0 -0
  89. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_threshold.py +0 -0
  90. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_typescript_conformance.py +0 -0
  91. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_wire_spec.py +0 -0
  92. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_witness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.18.0.dev0
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.18.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.18.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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 = json.loads((Path(args.out).resolve() / "public-keys.json").read_text())
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"],
@@ -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 grant = await signGrant(buildGrant({
233
- decisionSha256: env.decision_sha256, approvalRequestId: env.approval_request_id,
234
- grantId: newGrantId(), notAfter: isoUtc(new Date(Date.now() + state.ttl * 1000)),
235
- }), state.key, state.keyId);
236
- const res = await api("grants", { method: "POST", body: JSON.stringify(grant) });
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>`;