idemcore 0.1.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.
- idemcore/__init__.py +18 -0
- idemcore/_types.py +62 -0
- idemcore/_version.py +1 -0
- idemcore/client.py +486 -0
- idemcore/errors.py +89 -0
- idemcore/py.typed +0 -0
- idemcore/verify.py +394 -0
- idemcore-0.1.0.dist-info/METADATA +167 -0
- idemcore-0.1.0.dist-info/RECORD +12 -0
- idemcore-0.1.0.dist-info/WHEEL +5 -0
- idemcore-0.1.0.dist-info/licenses/LICENSE +202 -0
- idemcore-0.1.0.dist-info/top_level.txt +1 -0
idemcore/verify.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Local (offline) verification of Idemcore receipts, format r1.
|
|
3
|
+
|
|
4
|
+
Levels:
|
|
5
|
+
|
|
6
|
+
* ``L1``: receipt format and Ed25519 signature against a trusted key set.
|
|
7
|
+
Uses the ``cryptography`` package for Ed25519.
|
|
8
|
+
* ``L2``: the receipt's ``chain_digest`` and ``chain_fnv`` recomputed from the
|
|
9
|
+
answer's public token log (``GET /v1/receipts/{id}/log``). Standard library only;
|
|
10
|
+
the FNV-1a-64 chain comes from the ``idemcore-verify`` package.
|
|
11
|
+
* ``disclosure``: the prompt, output token ids and displayed text checked against the
|
|
12
|
+
salted commitments in the receipt.
|
|
13
|
+
|
|
14
|
+
L3 (re-running the answer on an independent build) is a server operation:
|
|
15
|
+
see :meth:`idemcore.Idemcore.replay`.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import struct
|
|
24
|
+
import unicodedata
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Union
|
|
27
|
+
|
|
28
|
+
from idemcore_verify.core import FNV_OFFSET, fnv1a64
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DEFAULT_TRUSTED_KEYS", "TrustedKey", "LevelResult", "VerificationResult",
|
|
32
|
+
"canonical_json", "check_format", "verify_l1", "verify_l2", "verify_disclosure",
|
|
33
|
+
"verify_receipt", "parse_public_log", "log_digests", "load_keys",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
#: Idemcore receipt-signing public keys (public halves only), bundled so receipts
|
|
37
|
+
#: verify offline. The live set is published at ``GET /v1/keys``.
|
|
38
|
+
DEFAULT_TRUSTED_KEYS: List[Dict[str, str]] = [
|
|
39
|
+
{"kty": "OKP", "crv": "Ed25519", "x": "AdQX-eipjpmqi4GDvqYHIep2Vb7l5Vas4xncNN453OY",
|
|
40
|
+
"kid": "ed25519:2fab59995ac55e4f"},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
RECEIPT_VERSION = "r1"
|
|
44
|
+
SIG_DOMAIN = b"idemcore-receipt/r1\n"
|
|
45
|
+
CHAIN_DOMAIN = b"idemcore/chain/r1\x00"
|
|
46
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
47
|
+
_HEX16 = re.compile(r"^[0-9a-f]{16}$")
|
|
48
|
+
_HEXF = re.compile(r"^[0-9a-fA-F]{1,16}$")
|
|
49
|
+
_ISSUED = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{1,6})?Z$")
|
|
50
|
+
_REQUIRED = {
|
|
51
|
+
"receipt_version": str, "receipt_id": str, "issued_at": str, "tenant_id": str,
|
|
52
|
+
"model_id": str, "weights_hash": str, "kernel_version": str, "engine_build": str,
|
|
53
|
+
"privacy": str, "prompt_hash": str, "params_hash": str, "seed": int, "decoding": str,
|
|
54
|
+
"prompt_tokens": int, "output_tokens": int, "output_token_hash": str,
|
|
55
|
+
"chain_digest": str, "chain_fnv": str, "log_ref": str, "key_id": str, "signature": str,
|
|
56
|
+
}
|
|
57
|
+
_OPTIONAL = {"finish_reason": str, "request_id": str, "output_text_hash": str}
|
|
58
|
+
_HASH_FIELDS = ("weights_hash", "prompt_hash", "params_hash", "output_token_hash", "chain_digest")
|
|
59
|
+
_SAFE_INT = 2 ** 53 - 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------ encoding helpers
|
|
63
|
+
def b64u(b: bytes) -> str:
|
|
64
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def b64u_decode(s: str) -> bytes:
|
|
68
|
+
if not isinstance(s, str) or not re.match(r"^[A-Za-z0-9_-]*$", s):
|
|
69
|
+
raise ValueError("not base64url")
|
|
70
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_canonical(v: Any, path: str = "$") -> None:
|
|
74
|
+
if isinstance(v, bool) or v is None or isinstance(v, str):
|
|
75
|
+
return
|
|
76
|
+
if isinstance(v, int):
|
|
77
|
+
if not -_SAFE_INT <= v <= _SAFE_INT:
|
|
78
|
+
raise ValueError(f"{path}: integer outside the safe range")
|
|
79
|
+
return
|
|
80
|
+
if isinstance(v, float):
|
|
81
|
+
raise ValueError(f"{path}: only integers are allowed")
|
|
82
|
+
if isinstance(v, (list, tuple)):
|
|
83
|
+
for i, x in enumerate(v):
|
|
84
|
+
_check_canonical(x, f"{path}[{i}]")
|
|
85
|
+
return
|
|
86
|
+
if isinstance(v, dict):
|
|
87
|
+
for k, x in v.items():
|
|
88
|
+
if not isinstance(k, str):
|
|
89
|
+
raise ValueError(f"{path}: object keys must be strings")
|
|
90
|
+
_check_canonical(x, f"{path}.{k}")
|
|
91
|
+
return
|
|
92
|
+
raise ValueError(f"{path}: unsupported type {type(v).__name__}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def canonical_json(obj: Any) -> bytes:
|
|
96
|
+
"""Canonical JSON (receipts r1): keys sorted by code point, no whitespace,
|
|
97
|
+
UTF-8 with non-ASCII literal, integers only."""
|
|
98
|
+
_check_canonical(obj)
|
|
99
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
100
|
+
allow_nan=False).encode("utf-8")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def tagged_hash(tag: str, *parts: bytes) -> str:
|
|
104
|
+
h = hashlib.sha256(f"idemcore/{tag}/r1".encode() + b"\x00")
|
|
105
|
+
for p in parts:
|
|
106
|
+
h.update(struct.pack("<Q", len(p)))
|
|
107
|
+
h.update(p)
|
|
108
|
+
return h.hexdigest()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_WS = re.compile("[\t\n\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def normalize_text(text: str) -> str:
|
|
115
|
+
"""Displayed-text normalisation: NFC, every whitespace run becomes one space, trimmed."""
|
|
116
|
+
s = _WS.sub(" ", unicodedata.normalize("NFC", text))
|
|
117
|
+
if s.startswith(" "):
|
|
118
|
+
s = s[1:]
|
|
119
|
+
if s.endswith(" "):
|
|
120
|
+
s = s[:-1]
|
|
121
|
+
return s
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def prompt_hash(messages: Sequence[Mapping[str, Any]], salt: bytes) -> str:
|
|
125
|
+
return tagged_hash("prompt", salt, canonical_json(list(messages)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def token_hash(ids: Sequence[int], salt: bytes) -> str:
|
|
129
|
+
for i, t in enumerate(ids):
|
|
130
|
+
if not isinstance(t, int) or isinstance(t, bool) or not 0 <= t <= 0xFFFFFFFF:
|
|
131
|
+
raise ValueError(f"token id out of range at {i}")
|
|
132
|
+
return tagged_hash("tokens", salt, struct.pack(f"<{len(ids)}I", *ids))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def text_hash(text: str, salt: bytes) -> str:
|
|
136
|
+
return tagged_hash("text", salt, normalize_text(text).encode("utf-8"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------ public token log
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class LogLine:
|
|
142
|
+
pi: int
|
|
143
|
+
j: int
|
|
144
|
+
h: int
|
|
145
|
+
hc: int
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_public_log(text: str):
|
|
149
|
+
"""Parse a public token log (``idemcore-log/r1-public``: ``pi j h hc`` per line
|
|
150
|
+
and an optional ``ALL <fnv>`` line). Returns ``(lines, all_value_or_None)``."""
|
|
151
|
+
lines: List[LogLine] = []
|
|
152
|
+
all_v: Optional[str] = None
|
|
153
|
+
for n, raw in enumerate(text.splitlines(), 1):
|
|
154
|
+
s = raw.strip()
|
|
155
|
+
if not s or s.startswith("#"):
|
|
156
|
+
continue
|
|
157
|
+
f = s.split()
|
|
158
|
+
if f[0] == "ALL":
|
|
159
|
+
if len(f) != 2 or not _HEXF.match(f[1]):
|
|
160
|
+
raise ValueError(f"log line {n}: malformed ALL line")
|
|
161
|
+
all_v = f[1].lower()
|
|
162
|
+
continue
|
|
163
|
+
if not (f[0].isdigit() and f[0].isascii()):
|
|
164
|
+
continue # reserved keyword lines
|
|
165
|
+
if len(f) != 4:
|
|
166
|
+
raise ValueError(f"log line {n}: not a public log line (expected 4 fields: pi j h hc)")
|
|
167
|
+
if not (f[1].isdigit() and f[1].isascii() and _HEXF.match(f[2]) and _HEXF.match(f[3])):
|
|
168
|
+
raise ValueError(f"log line {n}: malformed field")
|
|
169
|
+
ln = LogLine(int(f[0]), int(f[1]), int(f[2], 16), int(f[3], 16))
|
|
170
|
+
if ln.pi >= 2 ** 64 or ln.j >= 2 ** 64:
|
|
171
|
+
raise ValueError(f"log line {n}: index out of range")
|
|
172
|
+
lines.append(ln)
|
|
173
|
+
return lines, all_v
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def chain_fnv(lines: Iterable[LogLine]) -> str:
|
|
177
|
+
a = FNV_OFFSET
|
|
178
|
+
for ln in lines:
|
|
179
|
+
a = fnv1a64(struct.pack("<Q", ln.h), a)
|
|
180
|
+
a = fnv1a64(struct.pack("<Q", ln.hc), a)
|
|
181
|
+
return f"{a:016x}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def chain_digest(lines: Iterable[LogLine]) -> str:
|
|
185
|
+
c = hashlib.sha256(CHAIN_DOMAIN).digest()
|
|
186
|
+
for ln in lines:
|
|
187
|
+
c = hashlib.sha256(c + struct.pack("<QQQQ", ln.pi, ln.j, ln.h, ln.hc)).digest()
|
|
188
|
+
return c.hex()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def log_digests(text: str) -> Dict[str, Any]:
|
|
192
|
+
lines, all_v = parse_public_log(text)
|
|
193
|
+
return {"positions": len(lines), "chain_digest": chain_digest(lines),
|
|
194
|
+
"chain_fnv": chain_fnv(lines), "all_line": all_v}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------ keys
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class TrustedKey:
|
|
200
|
+
key_id: str
|
|
201
|
+
raw: bytes
|
|
202
|
+
source: str = "custom"
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_jwk(cls, jwk: Mapping[str, Any], source: str = "custom") -> "TrustedKey":
|
|
206
|
+
if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not isinstance(jwk.get("x"), str):
|
|
207
|
+
raise ValueError("not an Ed25519 public JWK")
|
|
208
|
+
raw = b64u_decode(jwk["x"])
|
|
209
|
+
if len(raw) != 32:
|
|
210
|
+
raise ValueError("Ed25519 public key must be 32 bytes")
|
|
211
|
+
kid = "ed25519:" + hashlib.sha256(raw).hexdigest()[:16]
|
|
212
|
+
if jwk.get("kid") and jwk["kid"] != kid:
|
|
213
|
+
raise ValueError(f"kid {jwk['kid']} does not match the key bytes ({kid})")
|
|
214
|
+
return cls(kid, raw, source)
|
|
215
|
+
|
|
216
|
+
def verify(self, sig: bytes, msg: bytes) -> bool:
|
|
217
|
+
try:
|
|
218
|
+
from cryptography.exceptions import InvalidSignature
|
|
219
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
220
|
+
except ImportError as e: # pragma: no cover - exercised only without the extra
|
|
221
|
+
raise ImportError("signature checks need the 'cryptography' package: pip install cryptography") from e
|
|
222
|
+
try:
|
|
223
|
+
Ed25519PublicKey.from_public_bytes(self.raw).verify(sig, msg)
|
|
224
|
+
return True
|
|
225
|
+
except (InvalidSignature, ValueError):
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
KeysLike = Union[None, Mapping[str, Any], Sequence[Any]]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_keys(keys: KeysLike = None, source: str = "custom") -> List[TrustedKey]:
|
|
233
|
+
"""Accept ``None`` (bundled keys), a JWK, a list of JWKs/TrustedKeys, or a JWKS
|
|
234
|
+
(``{"keys": [...]}``, as returned by ``GET /v1/keys``)."""
|
|
235
|
+
if keys is None:
|
|
236
|
+
return [TrustedKey.from_jwk(k, "bundled") for k in DEFAULT_TRUSTED_KEYS]
|
|
237
|
+
if isinstance(keys, Mapping):
|
|
238
|
+
keys = keys["keys"] if isinstance(keys.get("keys"), list) else [keys]
|
|
239
|
+
return [k if isinstance(k, TrustedKey) else TrustedKey.from_jwk(k, source) for k in keys]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------ results
|
|
243
|
+
@dataclass
|
|
244
|
+
class LevelResult:
|
|
245
|
+
level: str
|
|
246
|
+
ok: bool
|
|
247
|
+
errors: List[str] = field(default_factory=list)
|
|
248
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class VerificationResult:
|
|
253
|
+
ok: bool
|
|
254
|
+
levels: Dict[str, LevelResult]
|
|
255
|
+
|
|
256
|
+
def __bool__(self) -> bool:
|
|
257
|
+
return self.ok
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _res(level: str, errors: List[str], **details: Any) -> LevelResult:
|
|
261
|
+
return LevelResult(level, not errors, errors, details)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------ checks
|
|
265
|
+
def check_format(r: Any) -> List[str]:
|
|
266
|
+
if not isinstance(r, dict):
|
|
267
|
+
return ["receipt is not an object"]
|
|
268
|
+
errs: List[str] = []
|
|
269
|
+
for k, t in _REQUIRED.items():
|
|
270
|
+
if k not in r:
|
|
271
|
+
errs.append(f"missing field {k}")
|
|
272
|
+
elif not isinstance(r[k], t) or isinstance(r[k], bool):
|
|
273
|
+
errs.append(f"field {k} must be {t.__name__}")
|
|
274
|
+
for k in r:
|
|
275
|
+
if k not in _REQUIRED and k not in _OPTIONAL:
|
|
276
|
+
errs.append(f"unknown field {k}")
|
|
277
|
+
for k, t in _OPTIONAL.items():
|
|
278
|
+
if k in r and not isinstance(r[k], t):
|
|
279
|
+
errs.append(f"field {k} must be {t.__name__}")
|
|
280
|
+
if errs:
|
|
281
|
+
return errs
|
|
282
|
+
if r["receipt_version"] != RECEIPT_VERSION:
|
|
283
|
+
errs.append(f"unsupported receipt_version {r['receipt_version']}")
|
|
284
|
+
for k in _HASH_FIELDS:
|
|
285
|
+
if not _HEX64.match(r[k]):
|
|
286
|
+
errs.append(f"{k} must be 64 lowercase hex")
|
|
287
|
+
if "output_text_hash" in r and not _HEX64.match(r["output_text_hash"]):
|
|
288
|
+
errs.append("output_text_hash must be 64 lowercase hex")
|
|
289
|
+
if not _HEX16.match(r["chain_fnv"]):
|
|
290
|
+
errs.append("chain_fnv must be 16 lowercase hex")
|
|
291
|
+
if r["privacy"] not in ("hash_only", "full"):
|
|
292
|
+
errs.append("privacy must be hash_only or full")
|
|
293
|
+
if r["prompt_tokens"] < 1 or r["output_tokens"] < 0:
|
|
294
|
+
errs.append("token counts out of range")
|
|
295
|
+
if not _ISSUED.match(r["issued_at"]):
|
|
296
|
+
errs.append("issued_at must be RFC 3339 UTC (Z)")
|
|
297
|
+
try:
|
|
298
|
+
canonical_json(r)
|
|
299
|
+
except ValueError as e:
|
|
300
|
+
errs.append(str(e))
|
|
301
|
+
return errs
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def signing_payload(receipt: Mapping[str, Any]) -> bytes:
|
|
305
|
+
return SIG_DOMAIN + canonical_json({k: v for k, v in receipt.items() if k != "signature"})
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def verify_l1(receipt: Any, keys: KeysLike = None) -> LevelResult:
|
|
309
|
+
errs = check_format(receipt)
|
|
310
|
+
if errs:
|
|
311
|
+
return _res("L1", errs)
|
|
312
|
+
trusted = {k.key_id: k for k in load_keys(keys)}
|
|
313
|
+
pk = trusted.get(receipt["key_id"])
|
|
314
|
+
if pk is None:
|
|
315
|
+
return _res("L1", [f"unknown key_id {receipt['key_id']} (not in the trusted key set)"])
|
|
316
|
+
try:
|
|
317
|
+
sig = b64u_decode(receipt["signature"])
|
|
318
|
+
except ValueError:
|
|
319
|
+
return _res("L1", ["signature is not base64url"])
|
|
320
|
+
if len(sig) != 64 or not pk.verify(sig, signing_payload(receipt)):
|
|
321
|
+
return _res("L1", ["signature does not verify"])
|
|
322
|
+
return _res("L1", [], key_id=pk.key_id, key_source=pk.source)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def verify_l2(receipt: Mapping[str, Any], log_text: str) -> LevelResult:
|
|
326
|
+
try:
|
|
327
|
+
d = log_digests(log_text)
|
|
328
|
+
except ValueError as e:
|
|
329
|
+
return _res("L2", [f"log parse error: {e}"])
|
|
330
|
+
errs: List[str] = []
|
|
331
|
+
if d["chain_digest"] != receipt.get("chain_digest"):
|
|
332
|
+
errs.append("chain_digest mismatch")
|
|
333
|
+
if d["chain_fnv"] != receipt.get("chain_fnv"):
|
|
334
|
+
errs.append("chain_fnv mismatch")
|
|
335
|
+
if d["all_line"] is not None and d["all_line"] != d["chain_fnv"]:
|
|
336
|
+
errs.append("log ALL line inconsistent with its own lines")
|
|
337
|
+
want = int(receipt.get("prompt_tokens", 0)) + int(receipt.get("output_tokens", 0))
|
|
338
|
+
if d["positions"] != want:
|
|
339
|
+
errs.append(f"log covers {d['positions']} positions, receipt says {want}")
|
|
340
|
+
return _res("L2", errs, positions=d["positions"])
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _salt_bytes(salt: Union[None, str, bytes]) -> Optional[bytes]:
|
|
344
|
+
if salt is None or isinstance(salt, bytes):
|
|
345
|
+
return salt
|
|
346
|
+
return b64u_decode(salt)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def verify_disclosure(receipt: Mapping[str, Any], *, messages: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
350
|
+
salt: Union[None, str, bytes] = None, output_token_ids: Optional[Sequence[int]] = None,
|
|
351
|
+
output_text: Optional[str] = None) -> LevelResult:
|
|
352
|
+
"""Check disclosed values against the receipt. ``salt`` is the base64url string
|
|
353
|
+
returned with the answer (``idemcore_salt``) or raw bytes; ignored for ``full`` receipts."""
|
|
354
|
+
s = b"" if receipt.get("privacy") == "full" else _salt_bytes(salt)
|
|
355
|
+
if s is None:
|
|
356
|
+
return _res("disclosure", ["salt required for hash_only receipts"])
|
|
357
|
+
errs: List[str] = []
|
|
358
|
+
checked: List[str] = []
|
|
359
|
+
if messages is not None:
|
|
360
|
+
checked.append("prompt_hash")
|
|
361
|
+
if prompt_hash(messages, s) != receipt.get("prompt_hash"):
|
|
362
|
+
errs.append("prompt_hash mismatch")
|
|
363
|
+
if output_token_ids is not None:
|
|
364
|
+
checked.append("output_token_hash")
|
|
365
|
+
if token_hash(list(output_token_ids), s) != receipt.get("output_token_hash"):
|
|
366
|
+
errs.append("output_token_hash mismatch")
|
|
367
|
+
if output_text is not None:
|
|
368
|
+
if not isinstance(receipt.get("output_text_hash"), str):
|
|
369
|
+
errs.append("receipt has no output_text_hash; displayed text cannot be bound")
|
|
370
|
+
else:
|
|
371
|
+
checked.append("output_text_hash")
|
|
372
|
+
if text_hash(output_text, s) != receipt["output_text_hash"]:
|
|
373
|
+
errs.append("output_text_hash mismatch")
|
|
374
|
+
return _res("disclosure", errs, checked=checked)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def verify_receipt(receipt: Mapping[str, Any], *, keys: KeysLike = None, log_text: Optional[str] = None,
|
|
378
|
+
messages: Optional[Sequence[Mapping[str, Any]]] = None, salt: Union[None, str, bytes] = None,
|
|
379
|
+
output_token_ids: Optional[Sequence[int]] = None, output_text: Optional[str] = None,
|
|
380
|
+
check_signature: bool = True) -> VerificationResult:
|
|
381
|
+
"""Run every level the inputs allow. L1 always (unless ``check_signature=False``);
|
|
382
|
+
L2 when ``log_text`` is given; disclosure when any disclosed value is given."""
|
|
383
|
+
levels: Dict[str, LevelResult] = {}
|
|
384
|
+
if check_signature:
|
|
385
|
+
levels["L1"] = verify_l1(dict(receipt), keys)
|
|
386
|
+
else:
|
|
387
|
+
errs = check_format(dict(receipt))
|
|
388
|
+
levels["format"] = _res("format", errs)
|
|
389
|
+
if log_text is not None:
|
|
390
|
+
levels["L2"] = verify_l2(receipt, log_text)
|
|
391
|
+
if any(v is not None for v in (messages, output_token_ids, output_text)):
|
|
392
|
+
levels["disclosure"] = verify_disclosure(receipt, messages=messages, salt=salt,
|
|
393
|
+
output_token_ids=output_token_ids, output_text=output_text)
|
|
394
|
+
return VerificationResult(all(lv.ok for lv in levels.values()), levels)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idemcore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Idemcore API: chat completions with signed receipts, and local receipt verification.
|
|
5
|
+
Author-email: Don Wick <don.wick@idemcore.net>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://idemcore.net
|
|
8
|
+
Project-URL: Documentation, https://idemcore.net/docs
|
|
9
|
+
Project-URL: Verifier, https://github.com/idemcore/idemcore-verifier
|
|
10
|
+
Keywords: llm,inference,receipts,verification,reproducibility,openai-compatible
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: idemcore-verify>=0.3.0
|
|
27
|
+
Requires-Dist: cryptography>=41
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# idemcore (Python SDK)
|
|
31
|
+
|
|
32
|
+
Python client for the [Idemcore](https://idemcore.net) API. Every chat answer comes
|
|
33
|
+
with a signed receipt, and this package can check that receipt on your own machine.
|
|
34
|
+
|
|
35
|
+
* Chat completions (plain or streaming) that return the answer text **and** its receipt id.
|
|
36
|
+
* Fetch a receipt and its public token log.
|
|
37
|
+
* Verify receipts locally: Ed25519 signature (L1), token-log chain (L2), and your
|
|
38
|
+
prompt / answer against the receipt's salted commitments (disclosure).
|
|
39
|
+
* Server-side checks: remote verify, replay on an independent build (L3), audit export.
|
|
40
|
+
* Sync and asyncio clients. Python 3.9+. Dependencies: `idemcore-verify` and `cryptography`.
|
|
41
|
+
|
|
42
|
+
Decoding is greedy: **temperature is fixed at 0** (and `n` at 1). Sending any other
|
|
43
|
+
temperature raises `InvalidRequestError` before a request is made.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
pip install idemcore
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Get an API key
|
|
52
|
+
|
|
53
|
+
1. Sign in to the console at <https://console.idemcore.net/#/login> (the public
|
|
54
|
+
preview is free, with preview limits).
|
|
55
|
+
2. Create an API key. It starts with `ick_` and is shown only once.
|
|
56
|
+
3. Export it:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
export IDEMCORE_API_KEY="ick_..."
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The client reads `IDEMCORE_API_KEY` (and, optionally, `IDEMCORE_BASE_URL`, default
|
|
63
|
+
`https://api.idemcore.net`). You can also pass `Idemcore(api_key=...)`.
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from idemcore import Idemcore
|
|
69
|
+
|
|
70
|
+
client = Idemcore()
|
|
71
|
+
|
|
72
|
+
r = client.chat("Name the three primary colours.", max_tokens=64)
|
|
73
|
+
print(r.text)
|
|
74
|
+
print(r.receipt_id) # rcpt_...
|
|
75
|
+
|
|
76
|
+
# Verify on this machine: signature, token-log chain, and that the receipt
|
|
77
|
+
# commits to exactly this prompt and this answer.
|
|
78
|
+
result = client.verify_local(r)
|
|
79
|
+
print(result.ok) # True
|
|
80
|
+
for name, level in result.levels.items():
|
|
81
|
+
print(name, level.ok, level.errors)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`r.receipt` is the full signed receipt, `r.salt` the base64url salt of a
|
|
85
|
+
`hash_only` receipt (the default privacy mode). Anyone with the salt and the text
|
|
86
|
+
can check the commitments, so keep the salt with the conversation.
|
|
87
|
+
|
|
88
|
+
### Streaming
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
with client.chat_stream("Write a haiku about receipts.") as stream:
|
|
92
|
+
for chunk in stream:
|
|
93
|
+
print(chunk.delta, end="", flush=True)
|
|
94
|
+
|
|
95
|
+
print()
|
|
96
|
+
print(stream.result.receipt_id) # the receipt arrives with the last chunk
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Async
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import asyncio
|
|
103
|
+
from idemcore import AsyncIdemcore
|
|
104
|
+
|
|
105
|
+
async def main():
|
|
106
|
+
client = AsyncIdemcore()
|
|
107
|
+
r = await client.chat("Hello")
|
|
108
|
+
print(r.text, r.receipt_id)
|
|
109
|
+
|
|
110
|
+
asyncio.run(main())
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Receipts, logs and server-side checks
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
receipt = client.get_receipt("rcpt_...") # GET /v1/receipts/{id}
|
|
117
|
+
log = client.get_receipt_log("rcpt_...") # GET /v1/receipts/{id}/log
|
|
118
|
+
print(log.log_format) # idemcore-log/r1-public
|
|
119
|
+
|
|
120
|
+
client.verify_remote(r.receipt, messages=r.messages, salt=r.salt,
|
|
121
|
+
output_token_ids=r.output_token_ids) # POST /v1/receipts/verify
|
|
122
|
+
client.replay_result(r) # POST /v1/receipts/replay (L3)
|
|
123
|
+
lines = client.audit_export("2026-10-01", "2026-10-31") # POST /v1/audit/export (NDJSON lines)
|
|
124
|
+
client.models() # GET /v1/models
|
|
125
|
+
client.keys() # GET /v1/keys (signing keys, JWKS)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Offline verification without the client
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from idemcore.verify import verify_receipt
|
|
132
|
+
|
|
133
|
+
res = verify_receipt(receipt, log_text=log_text,
|
|
134
|
+
messages=messages, salt=salt, output_token_ids=output_ids)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The production receipt-signing public key is bundled
|
|
138
|
+
(`idemcore.verify.DEFAULT_TRUSTED_KEYS`); pass `keys=client.keys()` to use the live
|
|
139
|
+
key set instead.
|
|
140
|
+
|
|
141
|
+
## Errors
|
|
142
|
+
|
|
143
|
+
All errors derive from `idemcore.IdemcoreError` and carry `status`, `type` and
|
|
144
|
+
`message`: `AuthenticationError` (401), `PermissionDeniedError` (403, key lacks a
|
|
145
|
+
scope), `NotFoundError` (404), `ConflictError` (409), `InvalidRequestError` (400/415), `RateLimitError`
|
|
146
|
+
(429, see `.retry_after`), `InsufficientQuotaError` (429), `ServiceUnavailableError`
|
|
147
|
+
(503), `InternalServerError` (5xx), `APIConnectionError` (network). GET requests are
|
|
148
|
+
retried on 429/503 (`max_retries`, default 2); chat requests are never retried
|
|
149
|
+
automatically, so a retry never creates a second receipt behind your back.
|
|
150
|
+
|
|
151
|
+
## Using the OpenAI SDK instead
|
|
152
|
+
|
|
153
|
+
The API is OpenAI-compatible, so `openai.OpenAI(base_url="https://api.idemcore.net/v1",
|
|
154
|
+
api_key=...)` also works; the receipt is in the response's `idemcore_receipt` field.
|
|
155
|
+
This package adds typed receipts and local verification.
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
pip install -e .
|
|
161
|
+
python -m unittest discover -s tests # mocked API, no network
|
|
162
|
+
IDEMCORE_LIVE=1 python -m unittest tests.test_client.LiveSmoke # GET /health only
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
Apache-2.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
idemcore/__init__.py,sha256=nJ6-RZkaLp_P73CrEGaUZM6tMfuG8hA7ur7ZpwTPCFM,1182
|
|
2
|
+
idemcore/_types.py,sha256=VAAb42np799mWD88DYY8YvnnurZfLzkzQNOUqjDjVZc,1616
|
|
3
|
+
idemcore/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
4
|
+
idemcore/client.py,sha256=AIdirxQCMicsJY1II4ZkQ3NQ42st0w7kjeiIGVYSK78,22066
|
|
5
|
+
idemcore/errors.py,sha256=wRwUXTy-L3-MyPT8KedCHxvmvsQWM6XV31lw1Ey3O6Q,3058
|
|
6
|
+
idemcore/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
idemcore/verify.py,sha256=TO42kr-1eJ0J0mUKwtg40QsqMNgltJ8rtExToDdXVTg,16090
|
|
8
|
+
idemcore-0.1.0.dist-info/licenses/LICENSE,sha256=M7IiqWOs_rWTLS1hbDnGU3e5lhrKG1kyOjMT9MUzNtA,11339
|
|
9
|
+
idemcore-0.1.0.dist-info/METADATA,sha256=i6XZn0ONiJCEjpUC5xYUbKbjnajxo9fFkK3rD8M4T4s,5969
|
|
10
|
+
idemcore-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
idemcore-0.1.0.dist-info/top_level.txt,sha256=YB_A8U2PkxsxVPxjhuOVzqwcAEI9ryghmqdEZGXqC64,9
|
|
12
|
+
idemcore-0.1.0.dist-info/RECORD,,
|