keywright-server 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. keywright_server/__init__.py +3 -0
  2. keywright_server/__main__.py +71 -0
  3. keywright_server/access.py +131 -0
  4. keywright_server/access_view.py +144 -0
  5. keywright_server/actions.py +30 -0
  6. keywright_server/approvals.py +433 -0
  7. keywright_server/audit.py +58 -0
  8. keywright_server/bundles.py +87 -0
  9. keywright_server/config.py +102 -0
  10. keywright_server/db.py +119 -0
  11. keywright_server/discovery.py +277 -0
  12. keywright_server/events.py +78 -0
  13. keywright_server/friction.py +242 -0
  14. keywright_server/integrations.py +64 -0
  15. keywright_server/inventory.py +415 -0
  16. keywright_server/inventory_mcp.py +288 -0
  17. keywright_server/jev.py +80 -0
  18. keywright_server/knowledge.py +403 -0
  19. keywright_server/knowledge_chunking.py +118 -0
  20. keywright_server/knowledge_index.py +233 -0
  21. keywright_server/knowledge_mcp.py +82 -0
  22. keywright_server/llm.py +338 -0
  23. keywright_server/llm_proxy.py +271 -0
  24. keywright_server/llm_usage.py +99 -0
  25. keywright_server/mail.py +312 -0
  26. keywright_server/mcp_oauth.py +422 -0
  27. keywright_server/mcp_proxy.py +128 -0
  28. keywright_server/mcp_server.py +1025 -0
  29. keywright_server/models.py +442 -0
  30. keywright_server/network_map.py +128 -0
  31. keywright_server/notify/__init__.py +8 -0
  32. keywright_server/notify/base.py +156 -0
  33. keywright_server/notify/homeassistant.py +221 -0
  34. keywright_server/notify/log.py +30 -0
  35. keywright_server/notify/manager.py +131 -0
  36. keywright_server/notify/webhook.py +79 -0
  37. keywright_server/nudges.py +121 -0
  38. keywright_server/omada.py +179 -0
  39. keywright_server/policy.py +265 -0
  40. keywright_server/policy_preview.py +151 -0
  41. keywright_server/reconciliation.py +188 -0
  42. keywright_server/result_inspection.py +293 -0
  43. keywright_server/semantic.py +451 -0
  44. keywright_server/semantic_questions.json +120 -0
  45. keywright_server/settings_store.py +44 -0
  46. keywright_server/ssh.py +290 -0
  47. keywright_server/tool_access.py +260 -0
  48. keywright_server/usage.py +121 -0
  49. keywright_server/web/__init__.py +0 -0
  50. keywright_server/web/admin.py +1126 -0
  51. keywright_server/web/app.py +154 -0
  52. keywright_server/web/auth.py +144 -0
  53. keywright_server/web/discovery_ui.py +112 -0
  54. keywright_server/web/friction_ui.py +92 -0
  55. keywright_server/web/inventory_ui.py +192 -0
  56. keywright_server/web/knowledge_ui.py +177 -0
  57. keywright_server/web/mail_ui.py +190 -0
  58. keywright_server/web/mcp_oauth_ui.py +87 -0
  59. keywright_server/web/network_map_ui.py +37 -0
  60. keywright_server/web/oidc.py +231 -0
  61. keywright_server/web/security.py +77 -0
  62. keywright_server/web/templates/_activity_row.html +25 -0
  63. keywright_server/web/templates/_agent_setup.html +50 -0
  64. keywright_server/web/templates/_collections_style.html +88 -0
  65. keywright_server/web/templates/_constraints.html +6 -0
  66. keywright_server/web/templates/_decide.html +39 -0
  67. keywright_server/web/templates/_device_kind_picker.html +66 -0
  68. keywright_server/web/templates/_hazards.html +6 -0
  69. keywright_server/web/templates/_icons.html +19 -0
  70. keywright_server/web/templates/_infrastructure_nav.html +8 -0
  71. keywright_server/web/templates/_integration_style.html +30 -0
  72. keywright_server/web/templates/_inventory_forms.html +36 -0
  73. keywright_server/web/templates/_inventory_style.html +36 -0
  74. keywright_server/web/templates/_live.html +10 -0
  75. keywright_server/web/templates/_mail_nav.html +5 -0
  76. keywright_server/web/templates/_mail_style.html +10 -0
  77. keywright_server/web/templates/_network_map_script.html +5 -0
  78. keywright_server/web/templates/_nudge_card.html +12 -0
  79. keywright_server/web/templates/_patterns.html +6 -0
  80. keywright_server/web/templates/_policy_preview_results.html +13 -0
  81. keywright_server/web/templates/_request_card.html +46 -0
  82. keywright_server/web/templates/_semantic.html +10 -0
  83. keywright_server/web/templates/_topology_controller.js +217 -0
  84. keywright_server/web/templates/_topology_layout.js +174 -0
  85. keywright_server/web/templates/_topology_render.js +117 -0
  86. keywright_server/web/templates/access.html +119 -0
  87. keywright_server/web/templates/agents.html +49 -0
  88. keywright_server/web/templates/audit.html +6 -0
  89. keywright_server/web/templates/base.html +198 -0
  90. keywright_server/web/templates/dashboard.html +226 -0
  91. keywright_server/web/templates/decision.html +26 -0
  92. keywright_server/web/templates/friction.html +36 -0
  93. keywright_server/web/templates/friction_review.html +21 -0
  94. keywright_server/web/templates/grants.html +61 -0
  95. keywright_server/web/templates/integration_picker.html +47 -0
  96. keywright_server/web/templates/inventory.html +21 -0
  97. keywright_server/web/templates/inventory_asset.html +42 -0
  98. keywright_server/web/templates/inventory_discovery.html +32 -0
  99. keywright_server/web/templates/inventory_discovery_review.html +34 -0
  100. keywright_server/web/templates/inventory_map.html +45 -0
  101. keywright_server/web/templates/inventory_networks.html +12 -0
  102. keywright_server/web/templates/inventory_subnet.html +41 -0
  103. keywright_server/web/templates/inventory_topology.html +22 -0
  104. keywright_server/web/templates/knowledge.html +25 -0
  105. keywright_server/web/templates/knowledge_document.html +7 -0
  106. keywright_server/web/templates/knowledge_proposal.html +7 -0
  107. keywright_server/web/templates/login.html +9 -0
  108. keywright_server/web/templates/mail.html +45 -0
  109. keywright_server/web/templates/mail_rules.html +24 -0
  110. keywright_server/web/templates/mail_settings.html +19 -0
  111. keywright_server/web/templates/mcp_oauth_continue.html +14 -0
  112. keywright_server/web/templates/policies.html +30 -0
  113. keywright_server/web/templates/request_detail.html +27 -0
  114. keywright_server/web/templates/requests.html +9 -0
  115. keywright_server/web/templates/resource_form.html +198 -0
  116. keywright_server/web/templates/resources.html +76 -0
  117. keywright_server/web/templates/settings.html +53 -0
  118. keywright_server/web/templates/setup.html +8 -0
  119. keywright_server/web/templates/usage.html +84 -0
  120. keywright_server/web/usage_ui.py +41 -0
  121. keywright_server-0.0.1.dist-info/METADATA +152 -0
  122. keywright_server-0.0.1.dist-info/RECORD +127 -0
  123. keywright_server-0.0.1.dist-info/WHEEL +4 -0
  124. keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
  125. keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
  126. keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
  127. keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
@@ -0,0 +1,3 @@
1
+ """Keywright: just-in-time credential broker for AI agents."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import secrets
6
+ import sys
7
+
8
+
9
+ def main(argv: list[str] | None = None) -> int:
10
+ p = argparse.ArgumentParser(prog="keywright-server", description="Keywright server: JIT credential broker for AI agents")
11
+ sub = p.add_subparsers(dest="cmd", required=True)
12
+ s = sub.add_parser("serve", help="run the server")
13
+ s.add_argument("--host")
14
+ s.add_argument("--port", type=int)
15
+ a = sub.add_parser("create-agent", help="create an agent and print its token once")
16
+ a.add_argument("name")
17
+ sub.add_parser("set-admin-password", help="set/reset the admin UI password (prompts)")
18
+ args = p.parse_args(argv)
19
+
20
+ from .config import settings
21
+
22
+ if args.cmd == "serve":
23
+ import uvicorn
24
+
25
+ from .web.app import create_app
26
+
27
+ uvicorn.run(create_app(), host=args.host or settings.host, port=args.port or settings.port, log_level="info",
28
+ forwarded_allow_ips=settings.trusted_proxies or None)
29
+ return 0
30
+ if args.cmd == "create-agent":
31
+ return asyncio.run(_create_agent(args.name))
32
+ if args.cmd == "set-admin-password":
33
+ import getpass
34
+
35
+ pw = getpass.getpass("New admin password: ")
36
+ if pw != getpass.getpass("Repeat: "):
37
+ print("passwords differ", file=sys.stderr)
38
+ return 1
39
+ return asyncio.run(_set_password(pw))
40
+ return 1
41
+
42
+
43
+ async def _create_agent(name: str) -> int:
44
+ from .db import init_db, session
45
+ from .mcp_server import hash_token
46
+ from .models import Agent
47
+
48
+ await init_db()
49
+ token = "kw_" + secrets.token_urlsafe(32)
50
+ async with session() as db:
51
+ db.add(Agent(name=name, token_hash=hash_token(token), token_prefix=token[:8]))
52
+ await db.commit()
53
+ print(token)
54
+ return 0
55
+
56
+
57
+ async def _set_password(pw: str) -> int:
58
+ from .db import init_db, session
59
+ from .settings_store import warm
60
+ from .web.auth import set_password
61
+
62
+ await init_db()
63
+ async with session() as db:
64
+ await warm(db)
65
+ await set_password(db, pw)
66
+ print("ok")
67
+ return 0
68
+
69
+
70
+ if __name__ == "__main__":
71
+ sys.exit(main())
@@ -0,0 +1,131 @@
1
+ """Operation constraints shared by decisions, enforcement, and presentation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def operation_detail(detail: dict | None) -> dict:
10
+ """The single operation an automatic request was made for; empty for pattern requests and for
11
+ batched requests, whose operations come from `operations()`."""
12
+ if "operations" in (detail or {}):
13
+ return {}
14
+ # Requests from before constraints recorded user=None for the default account; a non-default
15
+ # account always carries its own ssh/<host>/user/<name> pattern, so dropping the key is safe.
16
+ return {k: v for k, v in (detail or {}).items()
17
+ if k not in ("bundles", "hazards") and not (k == "user" and v is None)}
18
+
19
+
20
+ def operations(detail: dict | None) -> list[tuple[list[str] | None, dict]]:
21
+ """(patterns, operation) pairs a decision binds to. A batched access_request stores one entry
22
+ per operation with the patterns it needs; an automatic request has one operation and the
23
+ request's own patterns (None here). Hazard flags travel with the operation for display but are
24
+ never part of what a grant binds to."""
25
+ if "operations" in (detail or {}):
26
+ return [(list(o["patterns"]), {k: v for k, v in o["operation"].items() if k != "hazards"})
27
+ for o in detail["operations"]]
28
+ op = operation_detail(detail)
29
+ return [(None, op)] if op else []
30
+
31
+
32
+ def binds_exactly(detail: dict | None) -> bool:
33
+ return bool(operations(detail))
34
+
35
+
36
+ def path_scoped(detail: dict | None) -> bool:
37
+ """A single file write. Its pattern already names host and path, so approving the patterns for
38
+ a short time means "any contents at this path, briefly": bounded in time instead of pinned to
39
+ a hash, which is what iterating on a script needs."""
40
+ return bool(detail) and "sha256" in detail and "operations" not in detail
41
+
42
+
43
+ def hazards_of(detail: dict | None) -> list[dict]:
44
+ """Every hazard flag on a request or operation, whichever shape it has."""
45
+ if not detail:
46
+ return []
47
+ if "operations" in detail:
48
+ out: list[dict] = []
49
+ for o in detail["operations"]:
50
+ for h in (o.get("operation") or {}).get("hazards") or []:
51
+ if h not in out:
52
+ out.append(h)
53
+ return out
54
+ return list(detail.get("hazards") or [])
55
+
56
+
57
+ def hazard_level(detail: dict | None) -> str | None:
58
+ from keywright.hazards import worst
59
+
60
+ return worst(hazards_of(detail))
61
+
62
+
63
+ def binds_operation(constraints: dict | None) -> bool:
64
+ """Whether constraints pin one exact operation (command, file digest, path or tool arguments),
65
+ as opposed to only the target identity."""
66
+ return bool(constraints) and any(k not in TARGET_KEYS for k in constraints)
67
+
68
+
69
+ TARGET_KEYS = ("resource_id", "host", "user", "server", "gateway", "to_resource_id", "to_host", "to_user")
70
+
71
+
72
+ def target_of(resource) -> dict:
73
+ """What a call with no operation (access_request, tools/list) is compared against: rules bound
74
+ only to this target apply, rules bound to an exact command or arguments do not."""
75
+ if resource.kind == "ssh":
76
+ return {"resource_id": resource.id, "host": resource.name, "user": resource.config.get("user") or "root"}
77
+ if resource.kind == "llm":
78
+ return {"resource_id": resource.id, "gateway": resource.name}
79
+ return {"resource_id": resource.id, "server": resource.name}
80
+
81
+
82
+ def constraints_for(detail: dict | None, extent: str) -> dict | None:
83
+ return operation_constraints(operation_detail(detail), extent)
84
+
85
+
86
+ def operation_constraints(operation: dict | None, extent: str) -> dict | None:
87
+ if not operation:
88
+ return None
89
+ if extent == "exact":
90
+ return operation
91
+ # Broader command/tool patterns still bind to the target identity and SSH account.
92
+ return {k: v for k, v in operation.items() if k in TARGET_KEYS} or None
93
+
94
+
95
+ def operation_matches(constraints: dict | None, operation: dict | None) -> bool:
96
+ if not constraints:
97
+ return True
98
+ if operation is None or not constraints.keys() <= operation.keys():
99
+ return False
100
+ # JSON equality must distinguish true from 1, unlike Python container equality.
101
+ try:
102
+ return json.dumps(constraints, sort_keys=True, allow_nan=False) == json.dumps(
103
+ {k: operation[k] for k in constraints}, sort_keys=True, allow_nan=False)
104
+ except ValueError:
105
+ # NaN and Infinity are not JSON and never equal themselves, so they match nothing.
106
+ return False
107
+
108
+
109
+ def constraint_label(constraints: dict[str, Any] | None) -> str:
110
+ if not constraints:
111
+ return "Permission patterns · any matching arguments"
112
+ if "command" in constraints:
113
+ return "Exact command"
114
+ if "arguments" in constraints:
115
+ return "Exact tool arguments"
116
+ if "sha256" in constraints:
117
+ return "Exact file contents"
118
+ if "path" in constraints:
119
+ return "Exact path"
120
+ if "user" in constraints:
121
+ return f"Permission patterns · as {constraints['user']}"
122
+ return "Permission patterns · bound to this target"
123
+
124
+
125
+ def grant_constraints(grant, request=None) -> dict | None:
126
+ """Legacy automatic once grants bind through their request rather than a stored constraint."""
127
+ if grant.constraints:
128
+ return grant.constraints
129
+ if grant.scope == "once" and request and request.decision_options is None:
130
+ return operation_detail(request.detail) or None
131
+ return None
@@ -0,0 +1,144 @@
1
+ """Human-readable access inventory, including all authorization sources and restrictions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import defaultdict
7
+
8
+ from keywright.patterns import covers, overlaps
9
+ from sqlalchemy import select
10
+
11
+ from .access import constraint_label, grant_constraints
12
+ from .models import AccessRequest, Agent, Grant, Policy, Resource, now
13
+
14
+
15
+ def remaining(expires_at, at) -> str:
16
+ if expires_at is None:
17
+ return "Until used or revoked"
18
+ seconds = (expires_at - at).total_seconds()
19
+ return "Expired" if seconds <= 0 else f"{max(1, math.ceil(seconds / 60))}m remaining"
20
+
21
+
22
+ async def overview(db) -> dict:
23
+ at = now()
24
+ agents = (await db.execute(select(Agent).order_by(Agent.name))).scalars().all()
25
+ resources = (await db.execute(select(Resource).order_by(Resource.kind, Resource.name))).scalars().all()
26
+ policies = (await db.execute(select(Policy).order_by(Policy.pattern))).scalars().all()
27
+ # Spent and expired grants are never revoked, so they pile up; leave them in the database.
28
+ grants = (await db.execute(select(Grant).where(
29
+ Grant.revoked_at.is_(None),
30
+ (Grant.expires_at.is_(None)) | (Grant.expires_at > at),
31
+ (Grant.uses_remaining.is_(None)) | (Grant.uses_remaining > 0),
32
+ ).order_by(Grant.created_at.desc()))).scalars().all()
33
+ grants = [g for g in grants if g.is_active(at, g.session_id)]
34
+ request_ids = {g.request_id for g in grants if g.request_id}
35
+ requests = {r.id: r for r in (await db.execute(select(AccessRequest).where(
36
+ AccessRequest.id.in_(request_ids)))).scalars().all()} if request_ids else {}
37
+ groups = defaultdict(list)
38
+ for g in grants:
39
+ groups[(g.agent_id, g.request_id or g.id)].append(g)
40
+ leases = []
41
+ for (agent_id, _), members in groups.items():
42
+ first = members[0]
43
+ req = requests.get(first.request_id)
44
+ leases.append({"agent_id": agent_id, "request": req, "grants": members,
45
+ "constraints": grant_constraints(first, req),
46
+ "label": constraint_label(grant_constraints(first, req)),
47
+ "remaining": remaining(first.expires_at, at), "scope": first.scope,
48
+ "session_id": first.session_id})
49
+ # Match every rule and grant against every target once, not once per agent.
50
+ scopes = {res.id: res.path_prefix + "/**" for res in resources}
51
+ rules_on = {rid: [p for p in policies if overlaps(p.pattern, scope)] for rid, scope in scopes.items()}
52
+ grants_on = {rid: [g for g in grants if overlaps(g.pattern, scope)] for rid, scope in scopes.items()}
53
+ by_agent = []
54
+ for agent in agents:
55
+ targets = []
56
+ for res in resources:
57
+ scope = scopes[res.id]
58
+ relevant = [p for p in rules_on[res.id] if p.agent_id in (None, agent.id)]
59
+ allows = [p for p in relevant if p.effect == "allow"]
60
+ denies = [p for p in relevant if p.effect == "deny"]
61
+ temporary = [g for g in grants_on[res.id] if g.agent_id == agent.id]
62
+ if agent.disabled:
63
+ state = "Suspended · new operations blocked"
64
+ elif any(not p.constraints and covers(p.pattern, scope) for p in denies):
65
+ state = "Denied for this target"
66
+ elif res.default_mode == "open":
67
+ state = "Allowed by default"
68
+ elif allows or temporary:
69
+ state = "Scoped access"
70
+ elif res.default_mode == "locked":
71
+ state = "Locked · standing rule required"
72
+ else:
73
+ state = "Approval required"
74
+ targets.append({"resource": res, "state": state, "allows": allows, "denies": denies,
75
+ "temporary": len(temporary), "has_restrictions": bool(denies)})
76
+ by_agent.append({"agent": agent, "targets": targets,
77
+ "leases": [g for g in leases if g["agent_id"] == agent.id]})
78
+ return {"access_agents": by_agent, "lease_count": len(leases), "target_count": len(resources),
79
+ "agent_count": len(agents)}
80
+
81
+
82
+ # ---- the Grants page: agent -> resource -> approval, newest first ---------------------------------
83
+ def grant_groups(grants: list[Grant], agents: dict[str, Agent], requests: dict[str, AccessRequest], at,
84
+ *, include_inactive: bool = False) -> list[dict]:
85
+ """Grants grouped the way a human reads them: by agent, then by the host or server they reach,
86
+ then by the approval that made them (one decision can grant several paths). Every level is
87
+ ordered by its most recent approval. Inactive grants are left out unless asked for."""
88
+ from keywright.patterns import resource_of
89
+
90
+ from . import approvals
91
+
92
+ def state(g: Grant) -> str:
93
+ if g.is_active(at, g.session_id):
94
+ return "active"
95
+ return "revoked" if g.revoked_at else "ended"
96
+
97
+ kept = [g for g in grants if include_inactive or state(g) == "active"]
98
+ request_resources: dict[str, set[str]] = defaultdict(set)
99
+ for g in grants:
100
+ if g.request_id and state(g) == "active":
101
+ request_resources[g.request_id].add("/".join(resource_of(g.pattern) or ("?", "?")))
102
+
103
+ tree: dict[str, dict[str, dict[str, list[Grant]]]] = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
104
+ for g in kept:
105
+ res = "/".join(resource_of(g.pattern) or ("?", g.pattern))
106
+ tree[g.agent_id][res][g.request_id or f"grant:{g.id}"].append(g)
107
+
108
+ def latest(gs):
109
+ return max(g.created_at for g in gs)
110
+
111
+ out = []
112
+ for agent_id, by_res in tree.items():
113
+ hosts = []
114
+ for res, by_req in by_res.items():
115
+ approvals_ = []
116
+ for key, gs in by_req.items():
117
+ gs.sort(key=lambda g: g.pattern)
118
+ req = requests.get(gs[0].request_id) if gs[0].request_id else None
119
+ states = {state(g) for g in gs}
120
+ st = "active" if "active" in states else ("revoked" if states == {"revoked"} else "ended")
121
+ expiry = max((g.expires_at for g in gs if g.expires_at), default=None)
122
+ elsewhere = len(request_resources.get(gs[0].request_id or "", set()) - {res})
123
+ approvals_.append({
124
+ "key": key, "grants": gs, "request": req, "approved_at": latest(gs), "state": st,
125
+ "label": approvals.decision_label(req) if req else approvals.SCOPE_LABELS.get(gs[0].scope, gs[0].scope),
126
+ "remaining": remaining(expiry, at) if st == "active" else None,
127
+ "uses": [g.uses_remaining for g in gs if g.uses_remaining is not None],
128
+ "session": next((g.session_id for g in gs if g.session_id), None),
129
+ "constraints": [(g, grant_constraints(g, req)) for g in gs],
130
+ "revoke": (f"/ui/requests/{req.id}/revoke" if req else f"/ui/grants/{gs[0].id}/revoke")
131
+ if st == "active" else None,
132
+ "elsewhere": elsewhere,
133
+ })
134
+ approvals_.sort(key=lambda a: a["approved_at"], reverse=True)
135
+ kind, _, name = res.partition("/")
136
+ hosts.append({"resource": res, "kind": kind, "name": name, "approvals": approvals_,
137
+ "latest": approvals_[0]["approved_at"],
138
+ "active": sum(1 for a in approvals_ if a["state"] == "active")})
139
+ hosts.sort(key=lambda h: h["latest"], reverse=True)
140
+ agent = agents.get(agent_id)
141
+ out.append({"agent": agent, "agent_id": agent_id, "name": agent.name if agent else "?", "hosts": hosts,
142
+ "latest": hosts[0]["latest"], "active": sum(h["active"] for h in hosts)})
143
+ out.sort(key=lambda a: a["latest"], reverse=True)
144
+ return out
@@ -0,0 +1,30 @@
1
+ """Compact authenticated actions for notification buttons: `<BODY>.<mac>`.
2
+
3
+ A phone renders a button as a short opaque string and hands it back when tapped. The MAC is what
4
+ carries the authority: knowing a request or nudge ID is not enough to act on one, and the strings
5
+ stay short enough for Android's action payload limits, which a signed token would not.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+
14
+ from .config import settings
15
+
16
+ MAC_BYTES = 16 # 128 bits, the same budget itsdangerous spends, in a third of the characters
17
+
18
+
19
+ def sign(body: str) -> str:
20
+ mac = hmac.new(settings.secret_key(), body.encode(), hashlib.sha256).digest()[:MAC_BYTES]
21
+ return body + "." + base64.urlsafe_b64encode(mac).decode().rstrip("=")
22
+
23
+
24
+ def verify(action: str) -> str | None:
25
+ """The body of an action we signed, or None. Constant-time; never raises."""
26
+ try:
27
+ body, _ = action.rsplit(".", 1)
28
+ except ValueError:
29
+ return None
30
+ return body if hmac.compare_digest(action, sign(body)) else None