@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0

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 (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -29,7 +29,6 @@ from pathlib import Path
29
29
  from typing import Any, Dict, List, Optional
30
30
 
31
31
  sys.path.insert(0, str(Path(__file__).resolve().parent))
32
- import fabric_delegation as _delegation # noqa: E402 - beside this file: RFC 8785
33
32
  import fabric_estate as _estate # noqa: E402 - beside this file: passkey assertions, times, show
34
33
 
35
34
  ENROLL_TOKEN_SCHEMA = "enroll-token/1"
@@ -62,8 +61,8 @@ def _finite(value: float) -> bool:
62
61
  def _register_ok(kind: str, fields: Dict[str, Any]) -> bool:
63
62
  if kind == ENROLL_TOKEN_TYPE:
64
63
  return (_matches(_ESTATE_SCOPE, fields["scope"]) and _matches(_SHA256, fields["genesis"]) and fields["purpose"] == ENROLL_PURPOSE
65
- and (fields["ceiling"] is None or _matches(_SHA256, fields["ceiling"])) and _finite(_estate.estate_time(fields["notBefore"]))
66
- and _finite(_estate.estate_time(fields["expiresAt"])) and _matches(_NONCE32, fields["nonce"]) and _is_one(fields["uses"]))
64
+ and (fields["ceiling"] is None or _matches(_SHA256, fields["ceiling"])) and _finite(_ftime.parse_rfc3339_ms(fields["notBefore"]))
65
+ and _finite(_ftime.parse_rfc3339_ms(fields["expiresAt"])) and _matches(_NONCE32, fields["nonce"]) and _is_one(fields["uses"]))
67
66
  return _matches(_SHA256, fields["digest"]) and _matches(_NONCE32, fields["nonce"])
68
67
 
69
68
 
@@ -77,7 +76,7 @@ def signing_statement(kind: str, fields: Any) -> Optional[bytes]:
77
76
  if not _register_ok(kind, fields):
78
77
  return None
79
78
  try:
80
- return _delegation.jcs(dict(fields, type=kind)).encode("utf-8")
79
+ return _jsv.jcs(dict(fields, type=kind)).encode("utf-8")
81
80
  except (TypeError, ValueError):
82
81
  return None
83
82
 
@@ -100,7 +99,7 @@ def verify_enroll_token(token: Any, *, record: Any, genesis: Any, at: Any) -> Di
100
99
  clock (an aware datetime or epoch seconds). The first failure answers: no-clock, malformed, wrong-estate, the passkey
101
100
  (no-trusted-authority, revoked-authority, duplicate-authority, unknown-authority), the assertion, then the window
102
101
  (too-long-lived, not-yet-valid, expired). Success: ``{"ok": True, "nonce", "expiresAt", "signedBy"}``."""
103
- now = _estate._clock(at)
102
+ now = _ftime.clock_ms(at)
104
103
  if not _finite(now):
105
104
  return {"ok": False, "problem": "no-clock"}
106
105
  fields = _statement_fields(token)
@@ -112,31 +111,31 @@ def verify_enroll_token(token: Any, *, record: Any, genesis: Any, at: Any) -> Di
112
111
  return {"ok": False, "problem": "malformed"}
113
112
  if fields["scope"] != "estate:%s" % record["estate"] or not isinstance(genesis, str) or fields["genesis"] != genesis:
114
113
  return {"ok": False, "problem": "wrong-estate"}
115
- authorities = _estate._list(record.get("authorities"))
114
+ authorities = _jsv.objects(record.get("authorities"))
116
115
  if not authorities:
117
116
  return {"ok": False, "problem": "no-trusted-authority"}
118
117
  credential_id = signature["credentialId"]
119
- if any(entry.get("kind") == "authority" and _estate._same(entry.get("ref", _estate._MISSING), credential_id) for entry in _estate._list(record.get("revocations"))):
118
+ if any(entry.get("kind") == "authority" and _jsv.same(entry.get("ref", _jsv.MISSING), credential_id) for entry in _jsv.objects(record.get("revocations"))):
120
119
  return {"ok": False, "problem": "revoked-authority"}
121
- matches = [entry for entry in authorities if _estate._same(entry.get("credentialId", _estate._MISSING), credential_id)]
120
+ matches = [entry for entry in authorities if _jsv.same(entry.get("credentialId", _jsv.MISSING), credential_id)]
122
121
  if len(matches) > 1:
123
122
  return {"ok": False, "problem": "duplicate-authority"}
124
123
  if not matches:
125
124
  return {"ok": False, "problem": "unknown-authority"}
126
125
  authority = matches[0]
127
126
  origins = authority.get("origins")
128
- rp_id = _estate._show(authority.get("rpId", _estate._MISSING))
127
+ rp_id = _jsv.show(authority.get("rpId", _jsv.MISSING))
129
128
  if not _estate.is_signing_rp_id(rp_id):
130
129
  return {"ok": False, "problem": "wrong-rp"}
131
- credential = {"credentialId": credential_id, "alg": _estate._show(authority.get("alg", _estate._MISSING)),
130
+ credential = {"credentialId": credential_id, "alg": _jsv.show(authority.get("alg", _jsv.MISSING)),
132
131
  "publicKey": authority["publicKey"] if isinstance(authority.get("publicKey"), dict) else {}, "rpId": rp_id,
133
132
  "origins": [o for o in origins if isinstance(o, str) and _estate.origin_within(o, rp_id)] if isinstance(origins, list) else []}
134
133
  challenge = hashlib.sha256(signing_statement(ENROLL_TOKEN_TYPE, fields)).digest()
135
134
  assertion = _estate.verify_assertion(signature, credential, challenge)
136
135
  if not assertion["ok"]:
137
136
  return {"ok": False, "problem": assertion["problem"]}
138
- not_before = _estate.estate_time(fields["notBefore"])
139
- expires_at = _estate.estate_time(fields["expiresAt"])
137
+ not_before = _ftime.parse_rfc3339_ms(fields["notBefore"])
138
+ expires_at = _ftime.parse_rfc3339_ms(fields["expiresAt"])
140
139
  if expires_at <= not_before or expires_at - not_before > MAX_ENROLL_TOKEN_SECONDS * 1000:
141
140
  return {"ok": False, "problem": "too-long-lived"}
142
141
  if now < not_before - _estate.CLOCK_SKEW_SECONDS * 1000:
@@ -177,8 +176,8 @@ def _token_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
177
176
  flag("/statement/purpose", "an enroll token's purpose is agent-host")
178
177
  if not _is_one(statement.get("uses")):
179
178
  flag("/statement/uses", "an enroll token is used once: uses is 1, and the hub binds its nonce on the first use")
180
- not_before = _estate.estate_time(statement.get("notBefore"))
181
- expires_at = _estate.estate_time(statement.get("expiresAt"))
179
+ not_before = _ftime.parse_rfc3339_ms(statement.get("notBefore"))
180
+ expires_at = _ftime.parse_rfc3339_ms(statement.get("expiresAt"))
182
181
  rest = {name: v for name, v in statement.items() if name != "type"}
183
182
  named = not extra and not missing and statement.get("purpose") == ENROLL_PURPOSE and _is_one(statement.get("uses")) and _finite(not_before) and _finite(expires_at)
184
183
  if named and signing_statement(ENROLL_TOKEN_TYPE, rest) is None:
@@ -201,20 +200,21 @@ _PROOF = re.compile(r"[A-Za-z0-9_-]{85}[AQgw]\Z")
201
200
 
202
201
 
203
202
  def _b64url(text: str) -> bytes:
203
+ """Strict base64url decode: a length the alphabet cannot produce raises, as the ceremony's readers expect."""
204
204
  return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
205
205
 
206
206
 
207
207
  def _es256_raw(octets: bytes, message: bytes, raw: bytes) -> bool:
208
208
  """ECDSA P-256 over SHA-256 with a 64-byte r || s signature, low-S only (relay-auth/1's form)."""
209
- if len(octets) != 65 or octets[0] != 4 or not _estate.is_valid_p256_key(octets) or len(raw) != 64:
209
+ if len(octets) != 65 or octets[0] != 4 or not _crypto.is_valid_p256_key(octets) or len(raw) != 64:
210
210
  return False
211
211
  r, s = int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big")
212
- n = _estate._N
212
+ n = _crypto._P256_N
213
213
  if not (1 <= r < n and 1 <= s <= n // 2):
214
214
  return False
215
215
  e = int.from_bytes(hashlib.sha256(message).digest(), "big")
216
216
  w = pow(s, -1, n)
217
- point = _estate._shamir(e * w % n, r * w % n, (int.from_bytes(octets[1:33], "big"), int.from_bytes(octets[33:], "big")))
217
+ point = _crypto._shamir(e * w % n, r * w % n, (int.from_bytes(octets[1:33], "big"), int.from_bytes(octets[33:], "big")))
218
218
  return point is not None and point[0] % n == r
219
219
 
220
220
 
@@ -227,7 +227,7 @@ def _proves(key: Dict[str, Any], message: str, proof: Any) -> bool:
227
227
  signature = _b64url(proof)
228
228
  if key.get("alg") == "es256":
229
229
  return _es256_raw(octets, message.encode("utf-8"), signature)
230
- return _delegation.ed25519_verify(octets, message.encode("utf-8"), signature)
230
+ return _crypto.ed25519_verify(octets, message.encode("utf-8"), signature)
231
231
 
232
232
 
233
233
  def redeem_enroll_token(token: Any, machine_key: Any, proof: Any, ledger: Any) -> Dict[str, Any]:
@@ -244,11 +244,11 @@ def redeem_enroll_token(token: Any, machine_key: Any, proof: Any, ledger: Any) -
244
244
  if not _proves(machine_key, join, proof):
245
245
  return {"ok": False, "problem": "bad-proof"}
246
246
  fingerprint = _estate.device_key_fingerprint(machine_key)
247
- entry = ledger.get(nonce, _estate._MISSING)
247
+ entry = ledger.get(nonce, _jsv.MISSING)
248
248
  if isinstance(entry, dict) and entry.get("state") == "revoked":
249
249
  return {"ok": False, "problem": "revoked"}
250
- if entry is not _estate._MISSING:
251
- bound = _estate._show(entry.get("fingerprint", _estate._MISSING)) if isinstance(entry, dict) else _estate._show(entry)
250
+ if entry is not _jsv.MISSING:
251
+ bound = _jsv.show(entry.get("fingerprint", _jsv.MISSING)) if isinstance(entry, dict) else _jsv.show(entry)
252
252
  return {"ok": False, "problem": "reused", "log": {"nonce": nonce, "presented": fingerprint, "boundTo": bound}}
253
253
  return {"ok": True, "record": {"nonce": nonce, "state": "redeemed", "fingerprint": fingerprint}}
254
254
 
@@ -268,13 +268,10 @@ _KEY_MEMBERS = ["alg", "kid", "publicKey", "use"]
268
268
  _ALGORITHMS = {"sig": ("es256", "ed25519"), "kem": ("ecdh-p256",)}
269
269
 
270
270
 
271
- def _json_equal(a: Any, b: Any) -> bool:
272
- """The contract's jsonEqual: arrays in order, objects by their members, primitives as JavaScript's ===."""
273
- if isinstance(a, list) or isinstance(b, list):
274
- return isinstance(a, list) and isinstance(b, list) and len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
275
- if isinstance(a, dict) or isinstance(b, dict):
276
- return isinstance(a, dict) and isinstance(b, dict) and len(a) == len(b) and all(k in b and _json_equal(a[k], b[k]) for k in a)
277
- return _estate._same(a, b)
271
+ from fabric_jsvalue import json_equal as _json_equal # noqa: E402,F401 - moved (FAA-65)
272
+ import fabric_time as _ftime # noqa: E402 - the core (FAA-65)
273
+ import fabric_jsvalue as _jsv # noqa: E402 - the core (FAA-65)
274
+ import fabric_crypto as _crypto # noqa: E402 - the core (FAA-65)
278
275
 
279
276
 
280
277
  def _is_role(value: Any) -> bool:
@@ -337,7 +334,7 @@ def pairing_commit(side: Any) -> Dict[str, Any]:
337
334
  problem = _side_problem(side)
338
335
  if problem:
339
336
  return {"ok": False, "problem": problem}
340
- return {"ok": True, "commit": "sha256:" + _sha_hex(_delegation.jcs(dict({"type": COMMIT_TYPE, "role": side["role"]}, **_covered(side))))}
337
+ return {"ok": True, "commit": "sha256:" + _sha_hex(_jsv.jcs(dict({"type": COMMIT_TYPE, "role": side["role"]}, **_covered(side))))}
341
338
 
342
339
 
343
340
  def _message(value: Any, step: str) -> Optional[Dict[str, Any]]:
@@ -354,7 +351,7 @@ def check_reveal(commit: Any, reveal: Any, expected: Optional[str] = None) -> Di
354
351
  c, r = _message(commit, "commit"), _message(reveal, "reveal")
355
352
  if c is None or r is None:
356
353
  return {"ok": False, "problem": "malformed"}
357
- if not _estate._same(r["role"], c["role"]) or (expected is not None and not _estate._same(r["role"], expected)):
354
+ if not _jsv.same(r["role"], c["role"]) or (expected is not None and not _jsv.same(r["role"], expected)):
358
355
  return {"ok": False, "problem": "role-mismatch"}
359
356
  made = pairing_commit({k: v for k, v in r.items() if k not in ("schema", "step")})
360
357
  if not made["ok"]:
@@ -369,14 +366,14 @@ def comparison_code(new_reveal: Any, trusted_reveal: Any) -> Dict[str, Any]:
369
366
  a, b = _message(new_reveal, "reveal"), _message(trusted_reveal, "reveal")
370
367
  if a is None or b is None:
371
368
  return {"ok": False, "problem": "malformed"}
372
- if not _estate._same(a["role"], "new") or not _estate._same(b["role"], "trusted"):
369
+ if not _jsv.same(a["role"], "new") or not _jsv.same(b["role"], "trusted"):
373
370
  return {"ok": False, "problem": "role-mismatch" if _is_role(a["role"]) and _is_role(b["role"]) else "bad-role"}
374
371
  problem = _side_problem(a) or _side_problem(b)
375
372
  if problem:
376
373
  return {"ok": False, "problem": problem}
377
374
  if machine_of_keys(a["keys"]) == machine_of_keys(b["keys"]):
378
375
  return {"ok": False, "problem": "same-machine"}
379
- digest = hashlib.sha256(_delegation.jcs({"type": CODE_TYPE, "new": _covered(a), "trusted": _covered(b)}).encode("utf-8")).digest()
376
+ digest = hashlib.sha256(_jsv.jcs({"type": CODE_TYPE, "new": _covered(a), "trusted": _covered(b)}).encode("utf-8")).digest()
380
377
  return {"ok": True, "code": str(int.from_bytes(digest[:5], "big") % 10 ** CODE_DIGITS).zfill(CODE_DIGITS)}
381
378
 
382
379
 
@@ -384,7 +381,7 @@ def estate_fingerprint(digest: Any) -> Dict[str, Any]:
384
381
  """The estate's fingerprint from revision 1's digest: five groups of five digits a person compares."""
385
382
  if not _matches(_SHA256, digest):
386
383
  return {"ok": False, "problem": "malformed"}
387
- f = hashlib.sha256(_delegation.jcs({"type": FINGERPRINT_TYPE, "digest": digest}).encode("utf-8")).digest()
384
+ f = hashlib.sha256(_jsv.jcs({"type": FINGERPRINT_TYPE, "digest": digest}).encode("utf-8")).digest()
388
385
  groups = [str(int.from_bytes(f[5 * i:5 * i + 5], "big") % 10 ** FINGERPRINT_GROUP_DIGITS).zfill(FINGERPRINT_GROUP_DIGITS) for i in range(FINGERPRINT_GROUPS)]
389
386
  return {"ok": True, "fingerprint": " ".join(groups)}
390
387
 
@@ -403,8 +400,8 @@ def checked_comparison_code(new_commit: Any, new_reveal: Any, trusted_commit: An
403
400
  def rendezvous_verdict(opened_at: Any, messages: Any, at: Any) -> Dict[str, Any]:
404
401
  """A one-time rendezvous after the messages it carried, at ``at`` (an aware datetime or epoch seconds): open,
405
402
  complete, or burned with the reason and the index of the message that burned it."""
406
- opened = _estate.estate_time(opened_at)
407
- now = _estate._clock(at)
403
+ opened = _ftime.parse_rfc3339_ms(opened_at)
404
+ now = _ftime.clock_ms(at)
408
405
  if not _finite(opened) or not _finite(now) or not isinstance(messages, list):
409
406
  return {"state": "burned", "reason": "malformed", "index": -1}
410
407
  if now > opened + RENDEZVOUS_SECONDS * 1000 or now < opened - _estate.CLOCK_SKEW_SECONDS * 1000:
@@ -443,21 +440,21 @@ def _machine_of(record: Dict[str, Any], machine_id: Any) -> Optional[Dict[str, A
443
440
  machines = record.get("machines")
444
441
  if not isinstance(machines, list):
445
442
  return None
446
- return next((m for m in machines if isinstance(m, dict) and _estate._same(m.get("id", _estate._MISSING), machine_id)), None)
443
+ return next((m for m in machines if isinstance(m, dict) and _jsv.same(m.get("id", _jsv.MISSING), machine_id)), None)
447
444
 
448
445
 
449
446
  def _machine_revoked(record: Dict[str, Any], machine_id: Any) -> bool:
450
447
  revocations = record.get("revocations")
451
448
  return isinstance(revocations, list) and any(isinstance(e, dict) and e.get("kind") == "machine"
452
- and _estate._same(e.get("ref", _estate._MISSING), machine_id) for e in revocations)
449
+ and _jsv.same(e.get("ref", _jsv.MISSING), machine_id) for e in revocations)
453
450
 
454
451
 
455
452
  def join_listed(record: Any, new_reveal: Any, trusted_reveal: Any, genesis: Any) -> Dict[str, Any]:
456
453
  """What the new machine checks before it pins and applies: the estate is the one the code bound, and the record
457
454
  lists it with exactly the keys it revealed and the trusted machine with its revealed #sig key, revoking neither."""
458
455
  a, b = _message(new_reveal, "reveal"), _message(trusted_reveal, "reveal")
459
- if (not isinstance(record, dict) or a is None or b is None or not _estate._same(a["role"], "new")
460
- or not _estate._same(b["role"], "trusted") or _side_problem(a) or _side_problem(b)):
456
+ if (not isinstance(record, dict) or a is None or b is None or not _jsv.same(a["role"], "new")
457
+ or not _jsv.same(b["role"], "trusted") or _side_problem(a) or _side_problem(b)):
461
458
  return {"ok": False, "problem": "malformed"}
462
459
  if not isinstance(genesis, str) or b["genesis"] != genesis:
463
460
  return {"ok": False, "problem": "other-estate"}
@@ -0,0 +1,336 @@
1
+ """The kit's cryptography, once (FAA-65, layer L1): what the contract's verifiers need and nothing more.
2
+
3
+ - ``ed25519_verify(public_key, message, signature)`` (RFC 8032) and ``is_small_order_key`` with
4
+ ``SMALL_ORDER_ENCODINGS``: a key of small order verifies forged signatures and is refused (DEC-0045).
5
+ - ``is_valid_p256_key`` and ``es256_verify`` (ECDSA P-256 over SHA-256, the signature in DER) for passkeys.
6
+ - ``ed25519_sign`` and ``ed25519_seed_public_key`` (RFC 8032), which ``fabric_package`` signs with, over ``ed_mul_secret``.
7
+ - base64url without padding (``b64url``, ``b64url_bytes``, ``node_base64``) and ``sha256``.
8
+
9
+ Pure Python over integers, slow next to OpenSSL. Verification multiplies public values only and takes the short path;
10
+ a secret scalar goes through ``ed_mul_secret``, a ladder that runs the same 256 steps whatever its bits are (FAA-78,
11
+ finding 45). Python's integers do not promise constant-time arithmetic, so that removes the branch on the secret, not
12
+ every timing difference: the signer stays a local, one-shot command line, never a service that signs for others.
13
+ Standard library only; imports no other kit module.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+ import secrets
20
+ from typing import Optional, Tuple
21
+
22
+
23
+ # The small-order points of edwards25519 and their non-canonical encodings, sign bit cleared (the contract's
24
+ # src/ed25519.ts; libsodium's has_small_order list).
25
+ SMALL_ORDER_ENCODINGS = (
26
+ "0000000000000000000000000000000000000000000000000000000000000000", "0100000000000000000000000000000000000000000000000000000000000000",
27
+ "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
28
+ "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
29
+ "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
30
+ )
31
+
32
+
33
+ def is_small_order_key(raw: bytes) -> bool:
34
+ """Whether a 32-byte Ed25519 public key is a point of small order, in any encoding (CT-29)."""
35
+ if len(raw) != 32:
36
+ return True
37
+ return (raw[:31] + bytes([raw[31] & 0x7F])).hex() in SMALL_ORDER_ENCODINGS
38
+
39
+
40
+ _ED_P = 2 ** 255 - 19
41
+
42
+
43
+ _ED_L = 2 ** 252 + 27742317777372353535851937790883648493
44
+
45
+
46
+ _ED_D = (-121665 * pow(121666, _ED_P - 2, _ED_P)) % _ED_P
47
+
48
+
49
+ _ED_SQRT_M1 = pow(2, (_ED_P - 1) // 4, _ED_P)
50
+
51
+
52
+ def _ed_add(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
53
+ x1, y1, z1, t1 = a
54
+ x2, y2, z2, t2 = b
55
+ aa = (y1 - x1) * (y2 - x2) % _ED_P
56
+ bb = (y1 + x1) * (y2 + x2) % _ED_P
57
+ cc = 2 * t1 * t2 * _ED_D % _ED_P
58
+ dd = 2 * z1 * z2 % _ED_P
59
+ e, f, g, h = bb - aa, dd - cc, dd + cc, bb + aa
60
+ return (e * f % _ED_P, g * h % _ED_P, f * g % _ED_P, e * h % _ED_P)
61
+
62
+
63
+ def _ed_mul(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
64
+ result = (0, 1, 1, 0)
65
+ while scalar:
66
+ if scalar & 1:
67
+ result = _ed_add(result, point)
68
+ point = _ed_add(point, point)
69
+ scalar >>= 1
70
+ return result
71
+
72
+
73
+ def _ed_cswap(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int], bit: int) -> Tuple[Tuple[int, int, int, int], Tuple[int, int, int, int]]:
74
+ """``(b, a)`` when ``bit`` is 1, ``(a, b)`` when it is 0, by arithmetic rather than a branch on ``bit``."""
75
+ deltas = [(x - y) * bit for x, y in zip(a, b)]
76
+ return (tuple(x - d for x, d in zip(a, deltas)), tuple(y + d for y, d in zip(b, deltas))) # type: ignore[return-value]
77
+
78
+
79
+ def ed_mul_secret(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
80
+ """``[scalar]point`` for a secret scalar below 2**256: a Montgomery ladder over all 256 bits with a conditional
81
+ swap, so every scalar takes one addition and one doubling per bit and no step depends on a bit (finding 45). The
82
+ addition law is complete on edwards25519, so the ladder needs no special case for the identity or a doubling."""
83
+ r0, r1 = (0, 1, 1, 0), point
84
+ for i in range(255, -1, -1):
85
+ bit = (scalar >> i) & 1
86
+ r0, r1 = _ed_cswap(r0, r1, bit)
87
+ r1 = _ed_add(r0, r1)
88
+ r0 = _ed_add(r0, r0)
89
+ r0, r1 = _ed_cswap(r0, r1, bit)
90
+ return r0
91
+
92
+
93
+ def _secret_scalar(seed: bytes) -> Tuple[int, bytes]:
94
+ h = hashlib.sha512(seed).digest()
95
+ a = int.from_bytes(h[:32], "little")
96
+ a &= (1 << 254) - 8
97
+ a |= 1 << 254
98
+ return a, h[32:]
99
+
100
+
101
+ def ed25519_seed_public_key(seed: bytes) -> bytes:
102
+ """The 32-byte public key of a 32-byte Ed25519 seed (RFC 8032 §5.1.5)."""
103
+ return _ed_encode_point(ed_mul_secret(_secret_scalar(seed)[0], _ED_G))
104
+
105
+
106
+ def ed25519_sign(seed: bytes, message: bytes) -> bytes:
107
+ """A deterministic Ed25519 signature (RFC 8032 §5.1.6); the same seed and message give the bytes Node's crypto.sign
108
+ gives. Both secret multiplications (the key's scalar, the nonce) go through ``ed_mul_secret``."""
109
+ a, prefix = _secret_scalar(seed)
110
+ public = _ed_encode_point(ed_mul_secret(a, _ED_G))
111
+ r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _ED_L
112
+ big_r = _ed_encode_point(ed_mul_secret(r, _ED_G))
113
+ k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") % _ED_L
114
+ return big_r + ((r + k * a) % _ED_L).to_bytes(32, "little")
115
+
116
+
117
+ def _ed_decode_point(data: bytes) -> Optional[Tuple[int, int, int, int]]:
118
+ if len(data) != 32:
119
+ return None
120
+ y = int.from_bytes(data, "little")
121
+ sign = y >> 255
122
+ y &= (1 << 255) - 1
123
+ if y >= _ED_P:
124
+ return None
125
+ x2 = (y * y - 1) * pow(_ED_D * y * y + 1, _ED_P - 2, _ED_P) % _ED_P
126
+ if x2 == 0:
127
+ if sign:
128
+ return None
129
+ return (0, y, 1, 0)
130
+ x = pow(x2, (_ED_P + 3) // 8, _ED_P)
131
+ if (x * x - x2) % _ED_P:
132
+ x = x * _ED_SQRT_M1 % _ED_P
133
+ if (x * x - x2) % _ED_P:
134
+ return None
135
+ if x & 1 != sign:
136
+ x = _ED_P - x
137
+ return (x, y, 1, x * y % _ED_P)
138
+
139
+
140
+ def _ed_encode_point(point: Tuple[int, int, int, int]) -> bytes:
141
+ x, y, z, _ = point
142
+ zi = pow(z, _ED_P - 2, _ED_P)
143
+ x, y = x * zi % _ED_P, y * zi % _ED_P
144
+ return (y | ((x & 1) << 255)).to_bytes(32, "little")
145
+
146
+
147
+ _ED_G = _ed_decode_point(bytes.fromhex("5866666666666666666666666666666666666666666666666666666666666666"))
148
+
149
+
150
+ _ED_IDENTITY = (1).to_bytes(32, "little")
151
+
152
+
153
+ def ed25519_verify(public_key: bytes, message: bytes, signature: bytes) -> bool:
154
+ """RFC 8032 §5.1.7, cofactorless: [S]B == R + [k]A, compared by R's encoding. False, never an exception."""
155
+ if len(public_key) != 32 or len(signature) != 64:
156
+ return False
157
+ s = int.from_bytes(signature[32:], "little")
158
+ if s >= _ED_L:
159
+ return False
160
+ a = _ed_decode_point(public_key)
161
+ if a is None or _ed_encode_point(_ed_mul(8, a)) == _ED_IDENTITY:
162
+ return False # a small-order key would accept R = [S]B for a quarter or more of all messages; OpenSSL refuses it
163
+ k = int.from_bytes(hashlib.sha512(signature[:32] + public_key + message).digest(), "little") % _ED_L
164
+ neg_a = ((-a[0]) % _ED_P, a[1], a[2], (-a[3]) % _ED_P)
165
+ return secrets.compare_digest(_ed_encode_point(_ed_add(_ed_mul(s, _ED_G), _ed_mul(k, neg_a))), signature[:32])
166
+
167
+
168
+ def b64url_bytes(text: str) -> bytes:
169
+ """Buffer.from(text, "base64url") for text of the base64url alphabet: a dangling sixth bit group is ignored."""
170
+ return b64url_decode(text)
171
+
172
+
173
+ def b64url(data: bytes) -> str:
174
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
175
+
176
+
177
+ def node_base64(text: str) -> bytes:
178
+ """Buffer.from(text, "base64"): either alphabet, other characters skipped, stops at the first "=", a dangling
179
+ sixth bit group ignored."""
180
+ kept = []
181
+ for ch in text:
182
+ if ch == "=":
183
+ break
184
+ if ch.isascii() and (ch.isalnum() or ch in "+/-_"):
185
+ kept.append({"-": "+", "_": "/"}.get(ch, ch))
186
+ body = "".join(kept)
187
+ if len(body) % 4 == 1:
188
+ body = body[:-1]
189
+ return base64.b64decode(body + "=" * (-len(body) % 4))
190
+
191
+
192
+ def sha256(data: bytes) -> bytes:
193
+ return hashlib.sha256(data).digest()
194
+
195
+
196
+ _P256_P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
197
+
198
+
199
+ _P256_A = _P256_P - 3
200
+
201
+
202
+ _P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
203
+
204
+
205
+ _P256_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
206
+
207
+
208
+ _P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
209
+
210
+
211
+ _P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
212
+
213
+
214
+ def is_valid_p256_key(raw: bytes) -> bool:
215
+ """SEC 1 §3.2.2 for an uncompressed point: 0x04 ‖ X ‖ Y, X and Y below p, on y² = x³ − 3x + b. The cofactor is 1."""
216
+ if len(raw) != 65 or raw[0] != 0x04:
217
+ return False
218
+ x = int.from_bytes(raw[1:33], "big")
219
+ y = int.from_bytes(raw[33:], "big")
220
+ if x >= _P256_P or y >= _P256_P:
221
+ return False
222
+ return (y * y - (x * x * x + _P256_A * x + _P256_B)) % _P256_P == 0
223
+
224
+
225
+ def _jacobian_double(p: Tuple[int, int, int]) -> Tuple[int, int, int]:
226
+ x, y, z = p
227
+ if y == 0 or z == 0:
228
+ return (0, 1, 0)
229
+ ysq = y * y % _P256_P
230
+ s = 4 * x * ysq % _P256_P
231
+ zsq = z * z % _P256_P
232
+ m = 3 * (x - zsq) * (x + zsq) % _P256_P # a = −3
233
+ nx = (m * m - 2 * s) % _P256_P
234
+ ny = (m * (s - nx) - 8 * ysq * ysq) % _P256_P
235
+ nz = 2 * y * z % _P256_P
236
+ return (nx, ny, nz)
237
+
238
+
239
+ def _jacobian_add(p: Tuple[int, int, int], q: Tuple[int, int, int]) -> Tuple[int, int, int]:
240
+ if p[2] == 0:
241
+ return q
242
+ if q[2] == 0:
243
+ return p
244
+ z1z1 = p[2] * p[2] % _P256_P
245
+ z2z2 = q[2] * q[2] % _P256_P
246
+ u1 = p[0] * z2z2 % _P256_P
247
+ u2 = q[0] * z1z1 % _P256_P
248
+ s1 = p[1] * q[2] * z2z2 % _P256_P
249
+ s2 = q[1] * p[2] * z1z1 % _P256_P
250
+ if u1 == u2:
251
+ return _jacobian_double(p) if s1 == s2 else (0, 1, 0)
252
+ h = (u2 - u1) % _P256_P
253
+ r = (s2 - s1) % _P256_P
254
+ hh = h * h % _P256_P
255
+ hhh = h * hh % _P256_P
256
+ v = u1 * hh % _P256_P
257
+ nx = (r * r - hhh - 2 * v) % _P256_P
258
+ ny = (r * (v - nx) - s1 * hhh) % _P256_P
259
+ nz = h * p[2] * q[2] % _P256_P
260
+ return (nx, ny, nz)
261
+
262
+
263
+ def _shamir(u1: int, u2: int, q: Tuple[int, int]) -> Optional[Tuple[int, int]]:
264
+ """u1·G + u2·Q in affine form, or None at infinity."""
265
+ g = (_P256_GX, _P256_GY, 1)
266
+ qj = (q[0], q[1], 1)
267
+ both = _jacobian_add(g, qj)
268
+ out = (0, 1, 0)
269
+ for bit in range(max(u1.bit_length(), u2.bit_length()) - 1, -1, -1):
270
+ out = _jacobian_double(out)
271
+ a, b = (u1 >> bit) & 1, (u2 >> bit) & 1
272
+ if a and b:
273
+ out = _jacobian_add(out, both)
274
+ elif a:
275
+ out = _jacobian_add(out, g)
276
+ elif b:
277
+ out = _jacobian_add(out, qj)
278
+ if out[2] == 0:
279
+ return None
280
+ zinv = pow(out[2], -1, _P256_P)
281
+ return (out[0] * zinv * zinv % _P256_P, out[1] * zinv * zinv * zinv % _P256_P)
282
+
283
+
284
+ def _der_integer(data: bytes, at: int) -> Tuple[Optional[int], int]:
285
+ if at + 2 > len(data) or data[at] != 0x02:
286
+ return None, at
287
+ length = data[at + 1]
288
+ start = at + 2
289
+ if length == 0 or length > 33 or start + length > len(data):
290
+ return None, at
291
+ body = data[start:start + length]
292
+ if body[0] & 0x80:
293
+ return None, at # negative
294
+ if length > 1 and body[0] == 0 and not body[1] & 0x80:
295
+ return None, at # not minimal
296
+ return int.from_bytes(body, "big"), start + length
297
+
298
+
299
+ def _der_signature(signature: bytes) -> Optional[Tuple[int, int]]:
300
+ """An ASN.1 DER Ecdsa-Sig-Value (RFC 3279 §2.2.3), strictly: one SEQUENCE of two minimal positive INTEGERs."""
301
+ if len(signature) < 8 or signature[0] != 0x30:
302
+ return None
303
+ if signature[1] & 0x80:
304
+ return None # a P-256 signature fits a short-form length
305
+ if signature[1] != len(signature) - 2:
306
+ return None
307
+ r, at = _der_integer(signature, 2)
308
+ if r is None:
309
+ return None
310
+ s, at = _der_integer(signature, at)
311
+ if s is None or at != len(signature):
312
+ return None
313
+ return r, s
314
+
315
+
316
+ def es256_verify(x: bytes, y: bytes, message: bytes, signature: bytes) -> bool:
317
+ """ECDSA P-256 with SHA-256 over `message`, the signature in DER (FIPS 186-5 §6.4.2). False, never an exception."""
318
+ if not is_valid_p256_key(b"\x04" + x + y):
319
+ return False
320
+ parsed = _der_signature(signature)
321
+ if parsed is None:
322
+ return False
323
+ r, s = parsed
324
+ if not (1 <= r < _P256_N and 1 <= s < _P256_N):
325
+ return False
326
+ e = int.from_bytes(sha256(message), "big")
327
+ w = pow(s, -1, _P256_N)
328
+ point = _shamir(e * w % _P256_N, r * w % _P256_N, (int.from_bytes(x, "big"), int.from_bytes(y, "big")))
329
+ return point is not None and point[0] % _P256_N == r
330
+
331
+
332
+ def b64url_decode(segment: str) -> bytes:
333
+ """Base64url without padding, as Node reads it: a dangling sixth bit group is ignored."""
334
+ if len(segment) % 4 == 1:
335
+ segment = segment[:-1]
336
+ return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))