vouchwell 0.5.0__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.
vouchwell/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """Vouchwell for Python: tamper-evident receipts for what AI agents do.
2
+
3
+ Wire-compatible with the ``vouchwell`` npm packages: a log written here
4
+ passes ``vw verify``, a bundle exported here passes ``vw check``, and logs
5
+ and bundles written by the JavaScript side open and verify here.
6
+ """
7
+
8
+ __version__ = "0.5.0"
9
+
10
+ from .canonical import canonical_bytes, canonicalize
11
+ from .checkpoint import build_checkpoint, checkpoint_digest, cosign, sign_checkpoint, verify_checkpoint
12
+ from .keys import Identity, generate_identity, identity_from_pem, identity_from_public_key, key_id_for, sign, verify
13
+ from .log import ProofLog, verify_bundle
14
+ from .merkle import (
15
+ MerkleTree,
16
+ consistency_proof,
17
+ inclusion_proof,
18
+ leaf_hash,
19
+ merkle_root,
20
+ node_hash,
21
+ verify_consistency,
22
+ verify_inclusion,
23
+ )
24
+ from .record import PolicyDenied, Recorder
25
+ from .redact import redact
26
+ from .receipt import (
27
+ GENESIS_PREV,
28
+ build_receipt,
29
+ entry_hash,
30
+ open_seal,
31
+ receipt_digest,
32
+ seal,
33
+ sign_receipt,
34
+ verify_chain,
35
+ verify_receipt,
36
+ )
37
+ from .remote import HubError, push
38
+ from .unfinished import DEFAULT_GRACE_MS, find_unfinished
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "DEFAULT_GRACE_MS",
43
+ "GENESIS_PREV",
44
+ "HubError",
45
+ "Identity",
46
+ "MerkleTree",
47
+ "PolicyDenied",
48
+ "ProofLog",
49
+ "Recorder",
50
+ "build_checkpoint",
51
+ "build_receipt",
52
+ "canonical_bytes",
53
+ "canonicalize",
54
+ "checkpoint_digest",
55
+ "consistency_proof",
56
+ "cosign",
57
+ "entry_hash",
58
+ "find_unfinished",
59
+ "generate_identity",
60
+ "identity_from_pem",
61
+ "identity_from_public_key",
62
+ "inclusion_proof",
63
+ "key_id_for",
64
+ "leaf_hash",
65
+ "merkle_root",
66
+ "node_hash",
67
+ "open_seal",
68
+ "push",
69
+ "receipt_digest",
70
+ "redact",
71
+ "seal",
72
+ "sign",
73
+ "sign_checkpoint",
74
+ "sign_receipt",
75
+ "verify",
76
+ "verify_bundle",
77
+ "verify_chain",
78
+ "verify_checkpoint",
79
+ "verify_consistency",
80
+ "verify_inclusion",
81
+ "verify_receipt",
82
+ ]
vouchwell/canonical.py ADDED
@@ -0,0 +1,139 @@
1
+ """RFC 8785 (JCS) canonical JSON, byte-identical to packages/core/src/canonical.js.
2
+
3
+ Two implementations must produce the same bytes for the same receipt, or a
4
+ receipt signed in Python would not verify in JavaScript. Three details decide
5
+ that, and each is matched here exactly:
6
+
7
+ * object keys sort by UTF-16 code unit, not by code point;
8
+ * control characters escape as lowercase ``\\u00xx``, with the short forms for
9
+ ``\\b \\t \\n \\f \\r`` and ``"`` and ``\\``, and everything else is literal;
10
+ * numbers are written the way ECMAScript's ``Number::toString`` writes them,
11
+ with ``-0`` as ``0``, and NaN and infinities refused.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from decimal import Decimal
18
+ from typing import Any
19
+
20
+ __all__ = ["canonicalize", "canonical_bytes"]
21
+
22
+ _ESCAPES = {
23
+ 0x08: "\\b",
24
+ 0x09: "\\t",
25
+ 0x0A: "\\n",
26
+ 0x0C: "\\f",
27
+ 0x0D: "\\r",
28
+ 0x22: '\\"',
29
+ 0x5C: "\\\\",
30
+ }
31
+
32
+ #: The largest integer every JSON implementation represents exactly.
33
+ _MAX_SAFE_INTEGER = 2**53 - 1
34
+
35
+
36
+ def _encode_string(s: str) -> str:
37
+ out = ['"']
38
+ for ch in s:
39
+ cp = ord(ch)
40
+ esc = _ESCAPES.get(cp)
41
+ if esc is not None:
42
+ out.append(esc)
43
+ elif cp < 0x20:
44
+ out.append("\\u%04x" % cp)
45
+ elif 0xD800 <= cp <= 0xDFFF:
46
+ # A lone surrogate has no UTF-8 form. JavaScript's encoder writes
47
+ # U+FFFD in its place, so the bytes hashed there are these.
48
+ out.append("�")
49
+ else:
50
+ out.append(ch)
51
+ out.append('"')
52
+ return "".join(out)
53
+
54
+
55
+ def _es_number(x: float) -> str:
56
+ """ECMAScript Number::toString for a finite, non-zero float.
57
+
58
+ Python's ``repr`` already finds the shortest digit string that round-trips,
59
+ which is what ECMAScript uses too; only the layout differs.
60
+ """
61
+ sign = "-" if x < 0 else ""
62
+ d = Decimal(repr(abs(x)))
63
+ _, digits, exponent = d.as_tuple()
64
+ s = "".join(map(str, digits)).rstrip("0")
65
+ exponent += len(digits) - len(s) # account for the zeros just stripped
66
+ k = len(s)
67
+ n = exponent + k # the value is 0.s × 10^n
68
+
69
+ if k <= n <= 21:
70
+ body = s + "0" * (n - k)
71
+ elif 0 < n <= 21:
72
+ body = s[:n] + "." + s[n:]
73
+ elif -6 < n <= 0:
74
+ body = "0." + "0" * (-n) + s
75
+ else:
76
+ e = n - 1
77
+ exp = ("+" if e > 0 else "-") + str(abs(e))
78
+ body = (s if k == 1 else s[0] + "." + s[1:]) + "e" + exp
79
+ return sign + body
80
+
81
+
82
+ def _encode_number(n: Any) -> str:
83
+ if isinstance(n, bool): # bool is an int subclass; never a number here
84
+ raise TypeError("booleans are not numbers")
85
+ if isinstance(n, int):
86
+ if abs(n) <= _MAX_SAFE_INTEGER:
87
+ return str(n)
88
+ # Beyond 2**53 a JSON number is a double everywhere else, so it is
89
+ # treated as one here: the same value JavaScript would parse and print.
90
+ try:
91
+ n = float(n)
92
+ except OverflowError:
93
+ raise TypeError(f"cannot canonicalize {n}: too large for a JSON number") from None
94
+ if not math.isfinite(n):
95
+ raise TypeError(f"cannot canonicalize non-finite number: {n}")
96
+ if n == 0:
97
+ return "0"
98
+ if n.is_integer() and abs(n) <= _MAX_SAFE_INTEGER:
99
+ return str(int(n))
100
+ # Everything else, including whole numbers above 2**53, which ECMAScript
101
+ # prints as shortest round-trip digits padded with zeros, not exactly.
102
+ return _es_number(n)
103
+
104
+
105
+ def _sort_key(key: str) -> bytes:
106
+ # Big-endian UTF-16 bytes compare in the same order as UTF-16 code units,
107
+ # which is how JavaScript's default sort orders strings.
108
+ return key.encode("utf-16-be", "surrogatepass")
109
+
110
+
111
+ def canonicalize(value: Any) -> str:
112
+ """The RFC 8785 canonical form of a JSON-compatible value."""
113
+ if value is None:
114
+ return "null"
115
+ if value is True:
116
+ return "true"
117
+ if value is False:
118
+ return "false"
119
+ if isinstance(value, (int, float)):
120
+ return _encode_number(value)
121
+ if isinstance(value, str):
122
+ return _encode_string(value)
123
+ if isinstance(value, (list, tuple)):
124
+ return "[" + ",".join(canonicalize(v) for v in value) + "]"
125
+ if isinstance(value, dict):
126
+ for k in value:
127
+ if not isinstance(k, str):
128
+ raise TypeError(f"object keys must be strings, got {type(k).__name__}")
129
+ parts = [
130
+ _encode_string(k) + ":" + canonicalize(value[k])
131
+ for k in sorted(value, key=_sort_key)
132
+ ]
133
+ return "{" + ",".join(parts) + "}"
134
+ raise TypeError(f"cannot canonicalize {type(value).__name__}")
135
+
136
+
137
+ def canonical_bytes(value: Any) -> bytes:
138
+ """Canonical form as UTF-8: what actually gets hashed and signed."""
139
+ return canonicalize(value).encode("utf-8")
@@ -0,0 +1,100 @@
1
+ """Signed checkpoints: a log's size, root and head at a moment, signed by the
2
+ log and countersigned by witnesses. Mirrors packages/core/src/checkpoint.js."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+ from .hashing import CHECKPOINT_PREFIX, hash_object
9
+ from .keys import Identity, sign, verify
10
+ from .receipt import now_iso
11
+
12
+ CHECKPOINT_VERSION = 1
13
+
14
+
15
+ def build_checkpoint(*, log: str, size: int, root: str, head: str, ts: Optional[str] = None) -> dict:
16
+ return {"v": CHECKPOINT_VERSION, "log": log, "size": size, "root": root, "head": head, "ts": ts or now_iso()}
17
+
18
+
19
+ def checkpoint_digest(body: dict) -> bytes:
20
+ return hash_object(CHECKPOINT_PREFIX, body)
21
+
22
+
23
+ def sign_checkpoint(identity: Identity, body: dict, role: str = "log") -> dict:
24
+ return {"body": body, "sigs": [{"role": role, "kid": identity.kid, "sig": sign(identity, checkpoint_digest(body))}]}
25
+
26
+
27
+ def cosign(checkpoint: dict, witness: Identity) -> dict:
28
+ """Add (or replace) a witness signature."""
29
+ sig = sign(witness, checkpoint_digest(checkpoint["body"]))
30
+ others = [s for s in checkpoint["sigs"] if s.get("kid") != witness.kid]
31
+ return {"body": checkpoint["body"], "sigs": [*others, {"role": "witness", "kid": witness.kid, "sig": sig, "ts": now_iso()}]}
32
+
33
+
34
+ def verify_checkpoint(
35
+ checkpoint: dict,
36
+ keyring: dict,
37
+ min_witnesses: int = 0,
38
+ trusted_witnesses: Optional[dict] = None,
39
+ ) -> dict:
40
+ """Check a checkpoint's signatures.
41
+
42
+ With ``trusted_witnesses`` (kid → public key, obtained from outside the
43
+ evidence), witness signatures are checked against those keys only, and
44
+ witnesses not on the list are ignored rather than counted. Without it,
45
+ witness signatures are checked against ``keyring`` and count only as a
46
+ claim; ``min_witnesses`` then cannot be met from the evidence itself.
47
+ """
48
+ issues: list[str] = []
49
+ signers: list[str] = []
50
+ witnesses = 0
51
+ pinned = isinstance(trusted_witnesses, dict)
52
+ if not isinstance(checkpoint, dict) or not isinstance(checkpoint.get("body"), dict) or not isinstance(checkpoint.get("sigs"), list):
53
+ return {"ok": False, "issues": ["malformed checkpoint"], "signers": signers, "witnesses": 0, "pinned": pinned}
54
+ body = checkpoint["body"]
55
+ if body.get("v") != CHECKPOINT_VERSION:
56
+ issues.append(f"unsupported checkpoint version {body.get('v')}")
57
+ digest = checkpoint_digest(body)
58
+ has_log_sig = False
59
+ # Each witness counts once, however often its signature is repeated;
60
+ # keyed by public key, so one key pinned under two names is one witness.
61
+ counted: set = set()
62
+ for s in checkpoint["sigs"]:
63
+ if not isinstance(s, dict):
64
+ issues.append("malformed signature entry")
65
+ continue
66
+ kid, role = s.get("kid"), s.get("role")
67
+ if pinned and role == "witness":
68
+ if kid not in trusted_witnesses:
69
+ continue
70
+ if trusted_witnesses[kid] in counted:
71
+ continue
72
+ if verify(trusted_witnesses[kid], digest, s.get("sig", "")):
73
+ counted.add(trusted_witnesses[kid])
74
+ signers.append(kid)
75
+ witnesses += 1
76
+ else:
77
+ issues.append(f"invalid witness signature from {kid}")
78
+ continue
79
+ if pinned and role == "log" and kid in trusted_witnesses:
80
+ issues.append(f"signature from pinned witness {kid} is labelled as the log's")
81
+ continue
82
+ if kid not in keyring:
83
+ issues.append(f"no public key for signer {kid}")
84
+ continue
85
+ if not verify(keyring[kid], digest, s.get("sig", "")):
86
+ issues.append(f"invalid {role} signature from {kid}")
87
+ continue
88
+ if role == "witness":
89
+ if keyring[kid] in counted:
90
+ continue
91
+ counted.add(keyring[kid])
92
+ witnesses += 1 # unpinned: a claim, not evidence
93
+ signers.append(kid)
94
+ if role == "log":
95
+ has_log_sig = True
96
+ if not has_log_sig:
97
+ issues.append("checkpoint carries no valid log signature")
98
+ if witnesses < min_witnesses:
99
+ issues.append(f"only {witnesses} valid witness signature(s), policy requires {min_witnesses}")
100
+ return {"ok": not issues, "issues": issues, "signers": signers, "witnesses": witnesses, "pinned": pinned}
vouchwell/hashing.py ADDED
@@ -0,0 +1,72 @@
1
+ """Domain-separated SHA-256, and the byte encodings the wire format uses.
2
+
3
+ Mirrors packages/core/src/hash.js. Every hash carries a one-byte domain tag,
4
+ so a leaf can never also be read as an interior node (the second-preimage
5
+ attack RFC 6962 closed), nor a receipt digest as a checkpoint digest.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+ import re
14
+ from typing import Any
15
+
16
+ from .canonical import canonical_bytes
17
+
18
+ LEAF_PREFIX = b"\x00"
19
+ NODE_PREFIX = b"\x01"
20
+ RECEIPT_PREFIX = b"\x02"
21
+ CHECKPOINT_PREFIX = b"\x03"
22
+
23
+ _HEX = re.compile(r"^[0-9a-f]*$")
24
+ _B64URL = re.compile(r"^[A-Za-z0-9_-]*$")
25
+
26
+
27
+ def sha256(*parts: bytes) -> bytes:
28
+ h = hashlib.sha256()
29
+ for p in parts:
30
+ h.update(p)
31
+ return h.digest()
32
+
33
+
34
+ def hash_object(prefix: bytes, value: Any) -> bytes:
35
+ """SHA-256 of a value's canonical form, under a domain tag."""
36
+ return sha256(prefix, canonical_bytes(value))
37
+
38
+
39
+ def to_hex(b: bytes) -> str:
40
+ return b.hex()
41
+
42
+
43
+ def from_hex(s: str) -> bytes:
44
+ """Lowercase hex only, as the wire format writes it."""
45
+ if not isinstance(s, str) or not _HEX.match(s) or len(s) % 2:
46
+ raise ValueError(f"not lowercase hex: {str(s)[:32]!r}")
47
+ return bytes.fromhex(s)
48
+
49
+
50
+ def b64url(b: bytes) -> str:
51
+ """Unpadded base64url."""
52
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
53
+
54
+
55
+ def from_b64url(s: str) -> bytes:
56
+ """Decode unpadded base64url, accepting only the one canonical spelling.
57
+
58
+ A final character with unused bits set (``QB`` where ``QA`` is canonical)
59
+ decodes to the same bytes, so without this check two strings would name the
60
+ same key or signature. The JavaScript verifier refuses them; so does this.
61
+ """
62
+ if not isinstance(s, str) or not _B64URL.match(s) or len(s) % 4 == 1:
63
+ raise ValueError("not base64url")
64
+ raw = base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
65
+ if b64url(raw) != s:
66
+ raise ValueError("not canonical base64url")
67
+ return raw
68
+
69
+
70
+ def equal_bytes(a: bytes, b: bytes) -> bool:
71
+ """Constant-time comparison."""
72
+ return hmac.compare_digest(a, b)
@@ -0,0 +1,26 @@
1
+ """Adapters for agent frameworks. Each module imports its framework lazily, so
2
+ ``vouchwell`` itself still needs nothing but ``cryptography``.
3
+
4
+ - ``vouchwell.integrations.langchain``: ``record_tools`` for LangChain /
5
+ LangGraph tools.
6
+ - ``vouchwell.integrations.openai_agents``: ``record_tools`` for the OpenAI
7
+ Agents SDK's function tools.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from ..record import PolicyDenied
15
+
16
+
17
+ def refusal(err: PolicyDenied) -> str:
18
+ """What the model is told when a call is refused: the same words the MCP
19
+ proxy sends, so an agent behaves the same whichever way it is wrapped."""
20
+ d: Any = err.decision
21
+ rules = ", ".join(d.get("rules") or []) or "none"
22
+ return (
23
+ f"Blocked by Vouchwell policy. {d.get('reason') or ''}\n"
24
+ f"Rules: {rules}\n"
25
+ f"This refusal is recorded as receipt {err.receipt['seq']} in log {err.receipt['log']}."
26
+ )
@@ -0,0 +1,93 @@
1
+ """Record, and check, every call a LangChain or LangGraph agent makes to its tools.
2
+
3
+ from vouchwell import ProofLog, Recorder
4
+ from vouchwell.integrations.langchain import record_tools
5
+
6
+ rec = Recorder(ProofLog.open(".vouchwell"), agent="support-bot", principal="ops@acme.com", decide=my_rules)
7
+ tools = record_tools(rec, [search, refund]) # use these in place of the originals
8
+ agent = create_react_agent(model, tools)
9
+
10
+ Each wrapped tool records an intent before it runs and an outcome after,
11
+ exactly as ``Recorder.tool`` does. A call the ``decide`` hook refuses never
12
+ runs, and the model is told why, in the tool's reply, rather than the agent
13
+ crashing: the MCP proxy's behaviour.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Sequence
19
+
20
+ from ..record import PolicyDenied, Recorder
21
+ from . import refusal
22
+
23
+ try:
24
+ from langchain_core.tools import BaseTool, StructuredTool
25
+ except ImportError as err: # pragma: no cover - exercised only without the package
26
+ raise ImportError(
27
+ "vouchwell.integrations.langchain needs langchain-core: pip install langchain-core"
28
+ ) from err
29
+
30
+
31
+ def record_tools(recorder: Recorder, tools: Sequence[BaseTool]) -> list[BaseTool]:
32
+ """Wrap each tool; returns new tools with the same names, descriptions and
33
+ argument schemas. The originals are untouched."""
34
+ return [record_tool(recorder, t) for t in tools]
35
+
36
+
37
+ def record_tool(recorder: Recorder, tool: BaseTool) -> BaseTool:
38
+ """Wrap one tool. See ``record_tools``."""
39
+ if not isinstance(tool, BaseTool):
40
+ raise TypeError(f"record_tool expects a LangChain BaseTool, got {type(tool).__name__}")
41
+ structured = tool.args_schema is not None
42
+
43
+ def params_of(args: tuple, kwargs: dict) -> Any:
44
+ return dict(kwargs) if structured else (args[0] if args else kwargs.get("tool_input", ""))
45
+
46
+ def invoke_input(args: tuple, kwargs: dict) -> Any:
47
+ return dict(kwargs) if structured else params_of(args, kwargs)
48
+
49
+ def run(*args: Any, **kwargs: Any) -> Any:
50
+ try:
51
+ with recorder.call(tool.name, params_of(args, kwargs)) as c:
52
+ c.result = _content(tool.invoke(invoke_input(args, kwargs)))
53
+ return c.result
54
+ except PolicyDenied as err:
55
+ return refusal(err)
56
+
57
+ async def arun(*args: Any, **kwargs: Any) -> Any:
58
+ try:
59
+ with recorder.call(tool.name, params_of(args, kwargs)) as c:
60
+ c.result = _content(await tool.ainvoke(invoke_input(args, kwargs)))
61
+ return c.result
62
+ except PolicyDenied as err:
63
+ return refusal(err)
64
+
65
+ if structured:
66
+ return StructuredTool.from_function(
67
+ func=run,
68
+ coroutine=arun,
69
+ name=tool.name,
70
+ description=tool.description,
71
+ args_schema=tool.args_schema,
72
+ return_direct=tool.return_direct,
73
+ infer_schema=False,
74
+ )
75
+
76
+ def run_text(tool_input: str) -> Any:
77
+ return run(tool_input)
78
+
79
+ async def arun_text(tool_input: str) -> Any:
80
+ return await arun(tool_input)
81
+
82
+ return StructuredTool.from_function(
83
+ func=run_text,
84
+ coroutine=arun_text,
85
+ name=tool.name,
86
+ description=tool.description,
87
+ return_direct=tool.return_direct,
88
+ )
89
+
90
+
91
+ def _content(output: Any) -> Any:
92
+ """A ToolMessage is recorded as what it carries."""
93
+ return getattr(output, "content", output)
@@ -0,0 +1,55 @@
1
+ """Record, and check, every call an OpenAI Agents SDK agent makes to its function tools.
2
+
3
+ from agents import Agent, function_tool
4
+ from vouchwell import ProofLog, Recorder
5
+ from vouchwell.integrations.openai_agents import record_tools
6
+
7
+ rec = Recorder(ProofLog.open(".vouchwell"), agent="support-bot", principal="ops@acme.com", decide=my_rules)
8
+ agent = Agent(name="Support", tools=record_tools(rec, [lookup_order, refund]))
9
+
10
+ Each wrapped tool records an intent before it runs and an outcome after. A
11
+ call the ``decide`` hook refuses never runs; the model is told why in the
12
+ tool's reply. Tools that are not function tools (hosted tools such as web
13
+ search run on OpenAI's side) are passed through unchanged, since nothing
14
+ here sees them run.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ import json
21
+ from typing import Any, Sequence
22
+
23
+ from ..record import PolicyDenied, Recorder
24
+ from . import refusal
25
+
26
+ try:
27
+ from agents import FunctionTool
28
+ except ImportError as err: # pragma: no cover - exercised only without the package
29
+ raise ImportError(
30
+ "vouchwell.integrations.openai_agents needs the OpenAI Agents SDK: pip install openai-agents"
31
+ ) from err
32
+
33
+
34
+ def record_tools(recorder: Recorder, tools: Sequence[Any]) -> list[Any]:
35
+ """Wrap each function tool; returns new tools. The originals are untouched."""
36
+ return [record_tool(recorder, t) if isinstance(t, FunctionTool) else t for t in tools]
37
+
38
+
39
+ def record_tool(recorder: Recorder, tool: FunctionTool) -> FunctionTool:
40
+ """Wrap one function tool. See ``record_tools``."""
41
+ original = tool.on_invoke_tool
42
+
43
+ async def on_invoke_tool(ctx: Any, input_json: str) -> Any:
44
+ try:
45
+ params: Any = json.loads(input_json) if input_json else {}
46
+ except ValueError:
47
+ params = {"raw": input_json}
48
+ try:
49
+ with recorder.call(tool.name, params) as c:
50
+ c.result = await original(ctx, input_json)
51
+ return c.result
52
+ except PolicyDenied as err:
53
+ return refusal(err)
54
+
55
+ return dataclasses.replace(tool, on_invoke_tool=on_invoke_tool)
vouchwell/keys.py ADDED
@@ -0,0 +1,84 @@
1
+ """Ed25519 identities, matching packages/core/src/keys.js.
2
+
3
+ A key id is ``pw1`` plus the first 16 bytes of SHA-256 over the raw 32-byte
4
+ public key, in hex. Public keys and signatures travel as canonical, unpadded
5
+ base64url. Private keys are stored as PKCS#8 PEM, the same file the
6
+ JavaScript side writes, so either can open a log the other created.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Optional
13
+
14
+ from cryptography.exceptions import InvalidSignature
15
+ from cryptography.hazmat.primitives import serialization
16
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
17
+
18
+ from .hashing import b64url, from_b64url, sha256
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Identity:
23
+ kid: str
24
+ public_key: str
25
+ private_key: Optional[Ed25519PrivateKey] = None
26
+
27
+ @property
28
+ def can_sign(self) -> bool:
29
+ return self.private_key is not None
30
+
31
+
32
+ def key_id_for(raw_public: bytes) -> str:
33
+ return "pw1" + sha256(raw_public)[:16].hex()
34
+
35
+
36
+ def _raw(pub: Ed25519PublicKey) -> bytes:
37
+ return pub.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
38
+
39
+
40
+ def generate_identity() -> tuple[Identity, str]:
41
+ """A new identity, and its private key as PKCS#8 PEM for storing."""
42
+ key = Ed25519PrivateKey.generate()
43
+ raw = _raw(key.public_key())
44
+ pem = key.private_bytes(
45
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
46
+ ).decode("ascii")
47
+ return Identity(key_id_for(raw), b64url(raw), key), pem
48
+
49
+
50
+ def identity_from_pem(pem: str) -> Identity:
51
+ key = serialization.load_pem_private_key(pem.encode("ascii"), password=None)
52
+ if not isinstance(key, Ed25519PrivateKey):
53
+ raise TypeError(f"expected an Ed25519 private key, got {type(key).__name__}")
54
+ raw = _raw(key.public_key())
55
+ return Identity(key_id_for(raw), b64url(raw), key)
56
+
57
+
58
+ def identity_from_public_key(public_key: str) -> Identity:
59
+ raw = from_b64url(public_key)
60
+ if len(raw) != 32:
61
+ raise ValueError(f"Ed25519 public key must be 32 bytes, got {len(raw)}")
62
+ return Identity(key_id_for(raw), b64url(raw))
63
+
64
+
65
+ def sign(identity: Identity, message: bytes) -> str:
66
+ if identity.private_key is None:
67
+ raise ValueError(f"identity {identity.kid} is verify-only; cannot sign")
68
+ return b64url(identity.private_key.sign(message))
69
+
70
+
71
+ def verify(public_key: str, message: bytes, signature: str) -> bool:
72
+ """Never raises: a malformed key or signature is simply not valid."""
73
+ try:
74
+ sig = from_b64url(signature)
75
+ raw = from_b64url(public_key)
76
+ except (ValueError, TypeError):
77
+ return False
78
+ if len(sig) != 64 or len(raw) != 32:
79
+ return False
80
+ try:
81
+ Ed25519PublicKey.from_public_bytes(raw).verify(sig, message)
82
+ return True
83
+ except (InvalidSignature, ValueError):
84
+ return False