aimarket-oracle-sortes 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ # Package-local, and it exists for two reasons beyond the obvious.
2
+ #
3
+ # 1. It stops hatchling walking up. hatchling picks its build exclusion list by climbing from
4
+ # here until it finds a .gitignore. With no file at this level it reached
5
+ # oracles/.gitignore, whose `**/data/` rule is the only thing keeping
6
+ # data/sortes_signing_key — this oracle's Ed25519 PRIVATE key — out of the sdist. That rule
7
+ # lives outside the package, so building anywhere the ancestor is unreachable (a CI checkout
8
+ # of one subdirectory, a Docker build context, an rsync'd release dir) silently removes it.
9
+ # Reproduced 2026-07-30 on a sibling: an out-of-tree sdist build of oracles/oracles/chronos
10
+ # packaged its signing key in plaintext. Whoever holds a Sortes secret key can forge its
11
+ # VRF proofs, which is the entire product.
12
+ #
13
+ # 2. hatchling force-includes whichever exclusion file it found into the sdist, bypassing
14
+ # `exclude`, so that file WILL be published. Better it be this one than a map of every
15
+ # secret path in a private monorepo. This is what the publish gate caught here: the
16
+ # allow-list in pyproject.toml alone was not enough, because the force-include outranks it.
17
+ #
18
+ # The real guarantee is the allow-list in pyproject.toml ([tool.hatch.build.targets.sdist]
19
+ # include) plus the gate in scripts/publish_pypi.sh. This file is the third layer.
20
+
21
+ data/
22
+ *.egg-info/
23
+ dist/
24
+ build/
25
+ __pycache__/
26
+ .pytest_cache/
27
+ .venv/
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: aimarket-oracle-sortes
3
+ Version: 0.1.0
4
+ Summary: Sortes — true ECVRF (RFC 9381) verifiable-randomness oracle on oracle-core
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: aimarket-oracle-core>=0.2
8
+ Provides-Extra: dev
9
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
10
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
11
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Sortes — true ECVRF verifiable-randomness oracle
15
+
16
+ Sortes draws lots you can verify. It is a **true Verifiable Random Function**
17
+ implementing **ECVRF-EDWARDS25519-SHA512-TAI** exactly per
18
+ [RFC 9381](https://www.rfc-editor.org/rfc/rfc9381) (suite octet `0x03`, over the
19
+ `edwards25519` curve).
20
+
21
+ For a fixed `(public_key, alpha)` there is **exactly one** valid output `beta`.
22
+ The oracle's secret key selects it deterministically, so — unlike a trusted
23
+ randomness beacon — the oracle **cannot grind or bias the result**, and anyone
24
+ holding the VRF public key can **verify the draw offline** from the 80-byte proof.
25
+
26
+ ## Capabilities
27
+
28
+ | capability | price | what it does |
29
+ |---|---|---|
30
+ | `sortes.draw@v1` | $0.006 | Draw verifiable randomness for `alpha`. Returns `public_key`, proof (`gamma`, `c`, `s`, `pi`), `beta`, and `output` (uniform `num_bytes`). The secret key is never returned. |
31
+ | `sortes.verify@v1` | $0.001 | Verify `(public_key, alpha, pi)` offline and trustlessly → `{valid, beta}`. |
32
+
33
+ `alpha` is UTF-8 by default; prefix with `hex:` to pass raw bytes.
34
+
35
+ ## Why it is correct
36
+
37
+ The implementation is pure Python (`hashlib` + integer math) — the `edwards25519`
38
+ group arithmetic (RFC 8032 §5.1) and the ECVRF construction (RFC 9381 §5) are
39
+ written from scratch, no external crypto libraries. Correctness is proven by the
40
+ **RFC 9381 Appendix B.3 test vectors**: the tests assert that our implementation
41
+ reproduces the published `SK→PK`, the hash-to-curve point `H`, the proof
42
+ commitment `Gamma`, the VRF output `beta`, **and the full 80-byte proof `pi`**
43
+ byte-for-byte for all three vectors. The full-`pi` check pins the little-endian
44
+ `c || s` encoding mandated by RFC 9381 §5.5, so the proofs are byte-interoperable
45
+ with a conformant verifier (e.g. libsodium), not merely self-consistent; the tests
46
+ also confirm the RFC's own published `pi` bytes verify under our verifier. See
47
+ `tests/test_sortes.py`.
48
+
49
+ ## VRF keypair
50
+
51
+ The oracle holds one VRF keypair. The 32-byte secret key is resolved from
52
+ `SORTES_VRF_SK` (64 hex chars), else a persisted key at `SORTES_VRF_SK_PATH`
53
+ (default `data/sortes_vrf_sk`), else generated once and persisted so the public
54
+ key stays stable across restarts. The secret key never leaves the process; the
55
+ public key is published in every draw and in the manifest description.
56
+
57
+ ## Run
58
+
59
+ ```bash
60
+ # tests (from the monorepo root)
61
+ PYTHONPATH="oracles/core:oracles/oracles/sortes" \
62
+ platon/backend/.venv/bin/python -m pytest oracles/oracles/sortes/tests/ -q
63
+
64
+ # serve (port 9310 by default)
65
+ PYTHONPATH="oracles/core:oracles/oracles/sortes" python -m sortes.main
66
+ ```
67
+
68
+ ### Environment
69
+
70
+ | var | default | meaning |
71
+ |---|---|---|
72
+ | `SORTES_PORT` | `9310` | listen port |
73
+ | `SORTES_PUBLIC_URL` | `http://localhost:9310` | advertised base URL |
74
+ | `SORTES_SIGNING_KEY` | `data/sortes_signing_key` | manifest/receipt signing key path |
75
+ | `SORTES_VRF_SK` | _(unset)_ | explicit 32-byte VRF secret key (hex) |
76
+ | `SORTES_VRF_SK_PATH` | `data/sortes_vrf_sk` | persisted VRF secret key path |
77
+ | `SORTES_CORS_ORIGINS` | `*` | allowed CORS origins |
@@ -0,0 +1,64 @@
1
+ # Sortes — true ECVRF verifiable-randomness oracle
2
+
3
+ Sortes draws lots you can verify. It is a **true Verifiable Random Function**
4
+ implementing **ECVRF-EDWARDS25519-SHA512-TAI** exactly per
5
+ [RFC 9381](https://www.rfc-editor.org/rfc/rfc9381) (suite octet `0x03`, over the
6
+ `edwards25519` curve).
7
+
8
+ For a fixed `(public_key, alpha)` there is **exactly one** valid output `beta`.
9
+ The oracle's secret key selects it deterministically, so — unlike a trusted
10
+ randomness beacon — the oracle **cannot grind or bias the result**, and anyone
11
+ holding the VRF public key can **verify the draw offline** from the 80-byte proof.
12
+
13
+ ## Capabilities
14
+
15
+ | capability | price | what it does |
16
+ |---|---|---|
17
+ | `sortes.draw@v1` | $0.006 | Draw verifiable randomness for `alpha`. Returns `public_key`, proof (`gamma`, `c`, `s`, `pi`), `beta`, and `output` (uniform `num_bytes`). The secret key is never returned. |
18
+ | `sortes.verify@v1` | $0.001 | Verify `(public_key, alpha, pi)` offline and trustlessly → `{valid, beta}`. |
19
+
20
+ `alpha` is UTF-8 by default; prefix with `hex:` to pass raw bytes.
21
+
22
+ ## Why it is correct
23
+
24
+ The implementation is pure Python (`hashlib` + integer math) — the `edwards25519`
25
+ group arithmetic (RFC 8032 §5.1) and the ECVRF construction (RFC 9381 §5) are
26
+ written from scratch, no external crypto libraries. Correctness is proven by the
27
+ **RFC 9381 Appendix B.3 test vectors**: the tests assert that our implementation
28
+ reproduces the published `SK→PK`, the hash-to-curve point `H`, the proof
29
+ commitment `Gamma`, the VRF output `beta`, **and the full 80-byte proof `pi`**
30
+ byte-for-byte for all three vectors. The full-`pi` check pins the little-endian
31
+ `c || s` encoding mandated by RFC 9381 §5.5, so the proofs are byte-interoperable
32
+ with a conformant verifier (e.g. libsodium), not merely self-consistent; the tests
33
+ also confirm the RFC's own published `pi` bytes verify under our verifier. See
34
+ `tests/test_sortes.py`.
35
+
36
+ ## VRF keypair
37
+
38
+ The oracle holds one VRF keypair. The 32-byte secret key is resolved from
39
+ `SORTES_VRF_SK` (64 hex chars), else a persisted key at `SORTES_VRF_SK_PATH`
40
+ (default `data/sortes_vrf_sk`), else generated once and persisted so the public
41
+ key stays stable across restarts. The secret key never leaves the process; the
42
+ public key is published in every draw and in the manifest description.
43
+
44
+ ## Run
45
+
46
+ ```bash
47
+ # tests (from the monorepo root)
48
+ PYTHONPATH="oracles/core:oracles/oracles/sortes" \
49
+ platon/backend/.venv/bin/python -m pytest oracles/oracles/sortes/tests/ -q
50
+
51
+ # serve (port 9310 by default)
52
+ PYTHONPATH="oracles/core:oracles/oracles/sortes" python -m sortes.main
53
+ ```
54
+
55
+ ### Environment
56
+
57
+ | var | default | meaning |
58
+ |---|---|---|
59
+ | `SORTES_PORT` | `9310` | listen port |
60
+ | `SORTES_PUBLIC_URL` | `http://localhost:9310` | advertised base URL |
61
+ | `SORTES_SIGNING_KEY` | `data/sortes_signing_key` | manifest/receipt signing key path |
62
+ | `SORTES_VRF_SK` | _(unset)_ | explicit 32-byte VRF secret key (hex) |
63
+ | `SORTES_VRF_SK_PATH` | `data/sortes_vrf_sk` | persisted VRF secret key path |
64
+ | `SORTES_CORS_ORIGINS` | `*` | allowed CORS origins |
@@ -0,0 +1,55 @@
1
+ [project]
2
+ # Renamed from "sortes-oracle" on 2026-07-30, before first publish. Unprefixed short names on
3
+ # PyPI belong to other people and can install colliding top-level modules — the incident
4
+ # that made this a rule is recorded at the top of oracles/core/pyproject.toml. "sortes-oracle"
5
+ # was unclaimed, which is not safe but claimable: anyone taking it makes
6
+ # `pip install sortes-oracle` fetch their code into a namespace this repo imports.
7
+ name = "aimarket-oracle-sortes"
8
+ version = "0.1.0"
9
+ description = "Sortes — true ECVRF (RFC 9381) verifiable-randomness oracle on oracle-core"
10
+ # Both fields are required to publish, not cosmetic: `scripts/publish_pypi.sh` runs
11
+ # `twine check --strict`, which fails a distribution whose `long_description` is empty, so
12
+ # without `readme` the upload aborts after the build. Matches oracles/core.
13
+ readme = "README.md"
14
+ license = "Apache-2.0"
15
+ requires-python = ">=3.11"
16
+ dependencies = ["aimarket-oracle-core>=0.2"]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest>=8.3.0", "pytest-asyncio>=0.24.0", "httpx>=0.27.0"]
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["sortes"]
27
+
28
+ # An ALLOW-list, and deliberately not a deny-list — the same reasoning as oracles/core, which
29
+ # is the only place in this family that had one. With no sdist target declared, hatchling falls
30
+ # back to "everything not matched by a discovered .gitignore", and it walks UP for that file.
31
+ # Inside this monorepo it finds oracles/.gitignore, whose `**/data/` rule is the only thing
32
+ # keeping `data/sortes_signing_key` — this oracle's Ed25519 PRIVATE key — out of the tarball.
33
+ # Copy the package anywhere that ancestor is not reachable (a CI checkout of one subdirectory,
34
+ # a Docker build context, an rsync'd release dir) and the rule vanishes with it. That is not
35
+ # hypothetical: an out-of-tree sdist build of oracles/oracles/chronos packaged
36
+ # data/chronos_signing_key in plaintext. A PyPI version can be yanked but never withdrawn, so
37
+ # a key published once is published permanently, and whoever holds it can forge this oracle's
38
+ # proofs — which is the entire trust story of Sortes.
39
+ #
40
+ # An include list cannot be defeated by a missing file.
41
+ [tool.hatch.build.targets.sdist]
42
+ include = [
43
+ "/sortes",
44
+ "/tests",
45
+ "/README.md",
46
+ "/pyproject.toml",
47
+ ]
48
+ # hatchling adds .gitignore to an sdist on its own, regardless of `include`. It is not secret,
49
+ # but it is a map of where the secrets in a private monorepo live. Also caught by the gate in
50
+ # scripts/publish_pypi.sh, which is the point of a gate that does not trust the config.
51
+ exclude = ["/.gitignore"]
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_mode = "auto"
55
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """Sortes — a true ECVRF (RFC 9381) verifiable-randomness oracle."""
@@ -0,0 +1,260 @@
1
+ """Sortes oracle spec — true ECVRF (RFC 9381) draw + offline verify on oracle-core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import secrets
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from oracle_core import Capability, OracleSpec
11
+
12
+ from sortes import vrf
13
+
14
+ # Largest output an unbounded caller may request. The protocol layer does NOT
15
+ # validate input against input_schema, so the handler clamps num_bytes itself —
16
+ # otherwise a caller could ask for an arbitrarily large stretched output and pin
17
+ # CPU/memory. 64 is beta's native length; anything above is SHA512-stretched.
18
+ MAX_NUM_BYTES = 64
19
+ # Cap alpha so a single draw cannot be made to hash megabytes (DoS guard).
20
+ MAX_ALPHA_BYTES = 4096
21
+
22
+ SUITE_STRING = "ECVRF-EDWARDS25519-SHA512-TAI"
23
+
24
+
25
+ # --- VRF keypair management ------------------------------------------------
26
+ def _load_or_create_sk() -> bytes:
27
+ """Resolve the oracle's 32-byte VRF secret key.
28
+
29
+ Order of precedence:
30
+ 1) SORTES_VRF_SK env (64 hex chars) — explicit, e.g. for reproducible deploys.
31
+ 2) A persisted key at SORTES_VRF_SK_PATH (default data/sortes_vrf_sk).
32
+ 3) Freshly generated, then persisted so the public key is stable across
33
+ restarts (an oracle whose PK changes is useless — clients verify against it).
34
+ The secret key NEVER leaves this module; only the public key is exposed.
35
+ """
36
+ env_sk = os.environ.get("SORTES_VRF_SK", "").strip()
37
+ if env_sk:
38
+ sk = bytes.fromhex(env_sk)
39
+ if len(sk) != 32:
40
+ raise ValueError("SORTES_VRF_SK must be 32 bytes (64 hex chars)")
41
+ return sk
42
+
43
+ path = Path(os.environ.get("SORTES_VRF_SK_PATH", "data/sortes_vrf_sk"))
44
+ if path.exists():
45
+ sk = bytes.fromhex(path.read_text().strip())
46
+ if len(sk) == 32:
47
+ return sk
48
+
49
+ sk = secrets.token_bytes(32)
50
+ try:
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ path.write_text(sk.hex())
53
+ try:
54
+ os.chmod(path, 0o600)
55
+ except OSError:
56
+ pass
57
+ except OSError:
58
+ # Read-only FS (e.g. ephemeral test env): keep the in-memory key.
59
+ pass
60
+ return sk
61
+
62
+
63
+ _SK = _load_or_create_sk()
64
+ _PK = vrf.sk_to_pk(_SK)
65
+ PUBLIC_KEY_HEX = _PK.hex()
66
+
67
+
68
+ def _alpha_to_bytes(alpha: Any) -> bytes:
69
+ """Interpret the client seed `alpha`: only a 'hex:'-prefixed string is decoded as raw
70
+ bytes, everything else is UTF-8 encoded.
71
+
72
+ The docstring used to also promise that a "pure-even-length hex string" would be
73
+ decoded. It never was, and it must not be: a caller seeding with the literal word
74
+ "deadbeef" means those eight characters, not those four bytes. Guessing by shape makes
75
+ the wire format ambiguous, which is the one thing a VRF seed cannot afford — the proof
76
+ binds to exact bytes. The prefix is the disambiguator, and `draw` now emits it."""
77
+ if alpha is None:
78
+ raise ValueError("missing 'alpha'")
79
+ if isinstance(alpha, (bytes, bytearray)):
80
+ raw = bytes(alpha)
81
+ else:
82
+ s = str(alpha)
83
+ if s.startswith("hex:"):
84
+ raw = bytes.fromhex(s[4:])
85
+ else:
86
+ raw = s.encode("utf-8")
87
+ if len(raw) > MAX_ALPHA_BYTES:
88
+ raise ValueError(f"alpha too large (>{MAX_ALPHA_BYTES} bytes)")
89
+ return raw
90
+
91
+
92
+ # --- handlers --------------------------------------------------------------
93
+ def _draw(d: dict[str, Any]) -> dict[str, Any]:
94
+ alpha_raw = _alpha_to_bytes(d.get("alpha"))
95
+ num_bytes = max(1, min(int(d.get("num_bytes", 32)), MAX_NUM_BYTES))
96
+
97
+ # Dev/test escape hatch: a caller may pass an explicit 32-byte hex `sk` to make
98
+ # the draw reproducible against published test vectors. Falls back to the
99
+ # oracle's own persisted key, which is the production path.
100
+ #
101
+ # FAIL CLOSED. `sk` is absent from the published input_schema and the protocol
102
+ # layer does not validate input against that schema (oracle_core hands the raw
103
+ # dict to the handler), so before this gate any caller could obtain a perfectly
104
+ # well-formed `sortes.draw` response computed under a key THEY chose, with the
105
+ # response echoing their own derived `public_key`. Every downstream fairness
106
+ # claim collapses at that point: a draw certificate that quotes `public_key`
107
+ # from the response — rather than pinning a key published before the draw —
108
+ # is forgeable by any entrant. Verified reachable on the live oracle before
109
+ # this gate existed. Off unless an operator explicitly opts in.
110
+ sk = _SK
111
+ pk = _PK
112
+ dev_sk = d.get("sk")
113
+ if dev_sk is not None:
114
+ if os.environ.get("SORTES_ALLOW_CALLER_SK", "0").strip() not in ("1", "true", "TRUE"):
115
+ raise ValueError(
116
+ "caller-supplied `sk` is refused: the draw is only meaningful under "
117
+ "this oracle's own key. Set SORTES_ALLOW_CALLER_SK=1 to reproduce "
118
+ "published RFC 9381 vectors in a dev or test environment."
119
+ )
120
+ sk = bytes.fromhex(str(dev_sk))
121
+ if len(sk) != 32:
122
+ raise ValueError("sk must be 32 bytes (64 hex chars)")
123
+ pk = vrf.sk_to_pk(sk)
124
+
125
+ pi = vrf.prove(sk, alpha_raw)
126
+ gamma = pi[:32]
127
+ c = pi[32:48]
128
+ s = pi[48:80]
129
+ beta = vrf.proof_to_hash(pi)
130
+ assert beta is not None # our own proof always decodes
131
+ output = vrf.expand_output(beta, num_bytes)
132
+
133
+ return {
134
+ "suite": SUITE_STRING,
135
+ "public_key": pk.hex(),
136
+ # `hex:`-prefixed, so feeding this response straight back into sortes.verify@v1
137
+ # works. It used to emit a bare hex string, which _alpha_to_bytes UTF-8 encodes
138
+ # rather than decodes — so verify hashed the 18 ASCII characters "746573742d…"
139
+ # instead of the 9 bytes "test-seed" and answered valid:false for every honest
140
+ # proof the oracle had just issued. The two halves disagreed on the wire format.
141
+ "alpha": "hex:" + alpha_raw.hex(),
142
+ "alpha_hex": alpha_raw.hex(),
143
+ "gamma": gamma.hex(),
144
+ "c": c.hex(),
145
+ "s": s.hex(),
146
+ "pi": pi.hex(),
147
+ "beta": beta.hex(),
148
+ "output": output.hex(),
149
+ "num_bytes": num_bytes,
150
+ }
151
+
152
+
153
+ def _verify(d: dict[str, Any]) -> dict[str, Any]:
154
+ try:
155
+ pk = bytes.fromhex(str(d["public_key"]))
156
+ pi = bytes.fromhex(str(d["pi"]))
157
+ alpha_raw = _alpha_to_bytes(d.get("alpha"))
158
+ except (KeyError, ValueError, TypeError) as exc:
159
+ return {"valid": False, "beta": None, "error": f"malformed input: {exc}"}
160
+ beta = vrf.verify(pk, alpha_raw, pi)
161
+ if beta is None:
162
+ return {"valid": False, "beta": None}
163
+ return {"valid": True, "beta": beta.hex()}
164
+
165
+
166
+ SPEC = OracleSpec(
167
+ name="Sortes VRF Oracle",
168
+ product_id="prod-sortes",
169
+ description=(
170
+ "Sortes draws lots you can verify. A true ECVRF (RFC 9381, suite "
171
+ "ECVRF-EDWARDS25519-SHA512-TAI over edwards25519): the output beta is a "
172
+ "uniform pseudorandom string cryptographically BOUND to the oracle's public "
173
+ "key and the client's input alpha, and anyone can verify it OFFLINE from the "
174
+ "80-byte proof. Unlike a trusted beacon the oracle cannot grind the result — "
175
+ "for a fixed (public_key, alpha) exactly one valid output exists. "
176
+ f"VRF public key: {PUBLIC_KEY_HEX}."
177
+ ),
178
+ public_url=os.environ.get("SORTES_PUBLIC_URL", "http://localhost:9310"),
179
+ categories=["verifiable-randomness", "vrf", "rfc9381", "sortition", "agent-tooling"],
180
+ signing_key_path=os.environ.get("SORTES_SIGNING_KEY", "data/sortes_signing_key"),
181
+ related=["https://github.com/alexar76"],
182
+ capabilities=[
183
+ Capability(
184
+ capability_id="sortes.draw@v1",
185
+ product_id="prod-sortes",
186
+ description=(
187
+ "Draw verifiable randomness for an input alpha. Computes the ECVRF "
188
+ "proof pi and output beta with the oracle's secret key (never returned), "
189
+ "binding the result to (public_key, alpha). Returns the public_key, the "
190
+ "proof components (gamma, c, s, pi), beta, and `output` (num_bytes of "
191
+ "uniform randomness). Anyone can re-verify offline with sortes.verify@v1."
192
+ ),
193
+ handler=_draw,
194
+ input_schema={
195
+ "type": "object",
196
+ "required": ["alpha"],
197
+ "properties": {
198
+ "alpha": {
199
+ "type": "string",
200
+ "description": "Client seed/message. UTF-8 by default; prefix 'hex:' for raw bytes.",
201
+ },
202
+ "num_bytes": {
203
+ "type": "integer", "minimum": 1, "maximum": MAX_NUM_BYTES, "default": 32,
204
+ "description": "Length of the derived uniform `output` (beta is 64 bytes).",
205
+ },
206
+ },
207
+ },
208
+ output_schema={
209
+ "type": "object",
210
+ "required": ["public_key", "alpha", "pi", "beta", "output", "suite"],
211
+ "properties": {
212
+ "suite": {"type": "string"},
213
+ "public_key": {"type": "string"},
214
+ "alpha": {"type": "string"},
215
+ "gamma": {"type": "string"},
216
+ "c": {"type": "string"},
217
+ "s": {"type": "string"},
218
+ "pi": {"type": "string"},
219
+ "beta": {"type": "string"},
220
+ "output": {"type": "string"},
221
+ "num_bytes": {"type": "integer"},
222
+ },
223
+ },
224
+ price_per_call_usd=0.006,
225
+ p50_latency_ms=8,
226
+ success_rate_30d=0.999,
227
+ ),
228
+ Capability(
229
+ capability_id="sortes.verify@v1",
230
+ product_id="prod-sortes",
231
+ description=(
232
+ "Verify an ECVRF proof offline and trustlessly. Given (public_key, "
233
+ "alpha, pi) returns valid:true with the canonical beta iff the proof is "
234
+ "the unique correct VRF output for that (public_key, alpha); otherwise "
235
+ "valid:false. Needs no trust in the oracle."
236
+ ),
237
+ handler=_verify,
238
+ input_schema={
239
+ "type": "object",
240
+ "required": ["public_key", "alpha", "pi"],
241
+ "properties": {
242
+ "public_key": {"type": "string", "description": "VRF public key (32-byte hex)."},
243
+ "alpha": {"type": "string", "description": "The input that was drawn (UTF-8 or 'hex:' raw)."},
244
+ "pi": {"type": "string", "description": "The 80-byte ECVRF proof (hex)."},
245
+ },
246
+ },
247
+ output_schema={
248
+ "type": "object",
249
+ "required": ["valid"],
250
+ "properties": {
251
+ "valid": {"type": "boolean"},
252
+ "beta": {"type": ["string", "null"]},
253
+ },
254
+ },
255
+ price_per_call_usd=0.001,
256
+ p50_latency_ms=6,
257
+ success_rate_30d=0.999,
258
+ ),
259
+ ],
260
+ )
@@ -0,0 +1,21 @@
1
+ """Sortes FastAPI app — the whole AIMarket v2 surface comes from oracle-core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from oracle_core import create_app
8
+
9
+ from sortes.capabilities import SPEC
10
+
11
+ app = create_app(SPEC, cors_origins=os.environ.get("SORTES_CORS_ORIGINS", "*"))
12
+
13
+
14
+ def main() -> None:
15
+ import uvicorn
16
+
17
+ uvicorn.run("sortes.main:app", host="0.0.0.0", port=int(os.environ.get("SORTES_PORT", "9310")), reload=False)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()
@@ -0,0 +1,332 @@
1
+ """ECVRF-EDWARDS25519-SHA512-TAI — a true verifiable random function (RFC 9381).
2
+
3
+ Sortes draws lots: the output beta is a uniform pseudorandom string bound to a
4
+ public key Y and an input alpha. It is **verifiable offline** by anyone holding Y
5
+ and the 80-byte proof pi — and, crucially, the prover **cannot grind it**. For a
6
+ fixed (Y, alpha) there is exactly ONE valid (pi, beta); the secret key x selects
7
+ it deterministically, so the oracle has no freedom to bias the draw. That is the
8
+ property a trusted randomness beacon cannot offer: a beacon can re-roll until it
9
+ likes the result, a VRF cannot.
10
+
11
+ This file is pure Python (hashlib, int math) — no numpy, no external crypto. The
12
+ edwards25519 group arithmetic (RFC 8032 §5.1) is implemented from scratch, and the
13
+ ECVRF construction (RFC 9381 §5, suite ECVRF-EDWARDS25519-SHA512-TAI, suite octet
14
+ 0x03) is implemented so the RFC 9381 Appendix B.3 test vectors reproduce
15
+ bit-for-bit — including the full 80-byte proof pi, which pins the little-endian
16
+ c||s encoding a conformant verifier checks (see tests/test_sortes.py).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ from typing import Tuple
23
+
24
+ # --- field & curve constants (edwards25519, RFC 8032 §5.1) -----------------
25
+ P = 2**255 - 19 # field prime
26
+ # group order of the base point B
27
+ L = 2**252 + 27742317777372353535851937790883648493
28
+ COFACTOR = 8
29
+ # twist constant d = -121665/121666 mod p
30
+ D = (-121665 * pow(121666, P - 2, P)) % P
31
+ # sqrt(-1) mod p, used in point decompression
32
+ SQRT_M1 = pow(2, (P - 1) // 4, P)
33
+
34
+ SUITE = b"\x03" # ECVRF-EDWARDS25519-SHA512-TAI suite_string
35
+ PT_LEN = 32 # encoded point length (bytes)
36
+ C_LEN = 16 # challenge length (bytes)
37
+ Q_LEN = 32 # scalar length (bytes)
38
+
39
+
40
+ # --- low-level field helpers ----------------------------------------------
41
+ def _inv(z: int) -> int:
42
+ """Field inverse via Fermat's little theorem (p is prime)."""
43
+ return pow(z, P - 2, P)
44
+
45
+
46
+ def _sha512(*chunks: bytes) -> bytes:
47
+ h = hashlib.sha512()
48
+ for c in chunks:
49
+ h.update(c)
50
+ return h.digest()
51
+
52
+
53
+ # --- extended twisted-Edwards point arithmetic ------------------------------
54
+ # Points are kept in extended homogeneous coordinates (X, Y, Z, T) with
55
+ # x = X/Z, y = Y/Z and T = XY/Z. Addition formulas are the unified ones from
56
+ # RFC 8032 §5.1.4 (Hisil-Wong-Carter-Dawson), valid for all input pairs.
57
+ Point = Tuple[int, int, int, int]
58
+
59
+
60
+ def _base_point() -> Point:
61
+ # B = (x, 4/5) with the standard recovered x (RFC 8032 §5.1).
62
+ by = (4 * _inv(5)) % P
63
+ bx = _recover_x(by, 0)
64
+ return (bx, by, 1, (bx * by) % P)
65
+
66
+
67
+ def _recover_x(y: int, sign: int) -> int:
68
+ """Recover the x-coordinate from y and its sign bit (RFC 8032 §5.1.3)."""
69
+ if y >= P:
70
+ return -1 # malformed
71
+ x2 = ((y * y - 1) * _inv(D * y * y + 1)) % P
72
+ if x2 == 0:
73
+ if sign:
74
+ return -1 # x = 0 cannot have a set sign bit
75
+ return 0
76
+ # candidate sqrt
77
+ x = pow(x2, (P + 3) // 8, P)
78
+ if (x * x - x2) % P != 0:
79
+ x = (x * SQRT_M1) % P
80
+ if (x * x - x2) % P != 0:
81
+ return -1 # not a square -> not on curve
82
+ if (x & 1) != sign:
83
+ x = (P - x) % P
84
+ return x
85
+
86
+
87
+ def point_add(a: Point, b: Point) -> Point:
88
+ """Unified extended-coordinate addition (RFC 8032 §5.1.4)."""
89
+ x1, y1, z1, t1 = a
90
+ x2, y2, z2, t2 = b
91
+ aa = ((y1 - x1) * (y2 - x2)) % P
92
+ bb = ((y1 + x1) * (y2 + x2)) % P
93
+ cc = (t1 * 2 * D * t2) % P
94
+ dd = (z1 * 2 * z2) % P
95
+ e = (bb - aa) % P
96
+ f = (dd - cc) % P
97
+ g = (dd + cc) % P
98
+ h = (bb + aa) % P
99
+ x3 = (e * f) % P
100
+ y3 = (g * h) % P
101
+ t3 = (e * h) % P
102
+ z3 = (f * g) % P
103
+ return (x3, y3, z3, t3)
104
+
105
+
106
+ def scalar_mul(s: int, pt: Point) -> Point:
107
+ """Constant-shape double-and-add scalar multiplication."""
108
+ # neutral element (0, 1, 1, 0)
109
+ result: Point = (0, 1, 1, 0)
110
+ addend = pt
111
+ s = s % (L * COFACTOR) if s >= L * COFACTOR else s
112
+ while s > 0:
113
+ if s & 1:
114
+ result = point_add(result, addend)
115
+ addend = point_add(addend, addend)
116
+ s >>= 1
117
+ return result
118
+
119
+
120
+ def point_equal(a: Point, b: Point) -> bool:
121
+ x1, y1, z1, _ = a
122
+ x2, y2, z2, _ = b
123
+ if (x1 * z2 - x2 * z1) % P != 0:
124
+ return False
125
+ if (y1 * z2 - y2 * z1) % P != 0:
126
+ return False
127
+ return True
128
+
129
+
130
+ def point_encode(pt: Point) -> bytes:
131
+ """Compress to 32 bytes (RFC 8032 §5.1.2): y with x's sign in the top bit."""
132
+ x, y, z, _ = pt
133
+ zi = _inv(z)
134
+ x = (x * zi) % P
135
+ y = (y * zi) % P
136
+ out = bytearray(y.to_bytes(32, "little"))
137
+ out[31] |= (x & 1) << 7
138
+ return bytes(out)
139
+
140
+
141
+ def point_decode(data: bytes) -> Point | None:
142
+ """Decompress a 32-byte point; return None if it is not a valid curve point."""
143
+ if len(data) != 32:
144
+ return None
145
+ b = bytearray(data)
146
+ sign = (b[31] >> 7) & 1
147
+ b[31] &= 0x7F
148
+ y = int.from_bytes(b, "little")
149
+ if y >= P:
150
+ return None
151
+ x = _recover_x(y, sign)
152
+ if x < 0:
153
+ return None
154
+ return (x, y, 1, (x * y) % P)
155
+
156
+
157
+ def is_on_curve(pt: Point) -> bool:
158
+ """-x^2 + y^2 = 1 + d x^2 y^2 (curve equation, affine, via Z)."""
159
+ x, y, z, t = pt
160
+ zi = _inv(z)
161
+ xa = (x * zi) % P
162
+ ya = (y * zi) % P
163
+ lhs = (-xa * xa + ya * ya) % P
164
+ rhs = (1 + D * xa * xa * ya * ya) % P
165
+ return lhs == rhs
166
+
167
+
168
+ # --- scalar / integer string helpers (RFC 9381 §5.5) -----------------------
169
+ # For the edwards25519 ciphersuites RFC 9381 §5.5 fixes int_to_string /
170
+ # string_to_int to the LITTLE-endian convention of RFC 8032 §5.1.2. This must be
171
+ # little-endian: a big-endian implementation is self-consistent (its own verifier
172
+ # still accepts its proofs) and even reproduces Gamma and beta, but it emits the
173
+ # c||s tail of pi byte-reversed, so the 80-byte proof is NOT byte-interoperable
174
+ # with a conformant verifier (libsodium / the CFRG reference). See the full-pi
175
+ # RFC 9381 Appendix B.3 regression test in tests/test_sortes.py.
176
+ def int_to_string(x: int, length: int) -> bytes:
177
+ """int_to_string — little-endian fixed-length octet string (RFC 9381 §5.5)."""
178
+ return x.to_bytes(length, "little")
179
+
180
+
181
+ def string_to_int(b: bytes) -> int:
182
+ """string_to_int — little-endian octet string to nonnegative integer (RFC 9381 §5.5)."""
183
+ return int.from_bytes(b, "little")
184
+
185
+
186
+ def _clamp_scalar(h32: bytes) -> int:
187
+ """RFC 8032 Ed25519 scalar clamping of the first 32 bytes of SHA512(SK)."""
188
+ a = bytearray(h32)
189
+ a[0] &= 0xF8
190
+ a[31] &= 0x7F
191
+ a[31] |= 0x40
192
+ return int.from_bytes(a, "little")
193
+
194
+
195
+ # --- key expansion (RFC 8032 §5.1.5) ---------------------------------------
196
+ def expand_sk(sk: bytes) -> Tuple[int, bytes, bytes]:
197
+ """SK (32 bytes) -> (x scalar, truncated_hashed_sk, PK bytes)."""
198
+ if len(sk) != 32:
199
+ raise ValueError("secret key must be 32 bytes")
200
+ h = _sha512(sk)
201
+ x = _clamp_scalar(h[0:32])
202
+ truncated = h[32:64]
203
+ pk = point_encode(scalar_mul(x, _base_point()))
204
+ return x, truncated, pk
205
+
206
+
207
+ def sk_to_pk(sk: bytes) -> bytes:
208
+ """Derive the 32-byte Ed25519/VRF public key from a 32-byte secret key."""
209
+ return expand_sk(sk)[2]
210
+
211
+
212
+ # --- ECVRF core (RFC 9381 §5.4 / §5.1) -------------------------------------
213
+ def hash_to_curve_tai(pk: bytes, alpha: bytes) -> Point:
214
+ """ECVRF_hash_to_curve_try_and_increment (§5.4.1.1).
215
+
216
+ H = cofactor * (first decodable point of SHA512(suite||0x01||PK||alpha||ctr||0x00)).
217
+ """
218
+ for ctr in range(256):
219
+ h = _sha512(SUITE, b"\x01", pk, alpha, int_to_string(ctr, 1), b"\x00")
220
+ pt = point_decode(h[0:PT_LEN])
221
+ if pt is not None:
222
+ # cofactor clearing: H <- 8 * H
223
+ return scalar_mul(COFACTOR, pt)
224
+ raise ValueError("hash_to_curve failed (no point found in 256 tries)")
225
+
226
+
227
+ def _challenge_generation(points: list[Point]) -> int:
228
+ """ECVRF_challenge_generation (§5.4.3): c = int(SHA512(suite||0x02||P1..Pn||0x00)[0:16])."""
229
+ parts = [SUITE, b"\x02"]
230
+ for p in points:
231
+ parts.append(point_encode(p))
232
+ parts.append(b"\x00")
233
+ c_string = _sha512(*parts)
234
+ return string_to_int(c_string[0:C_LEN])
235
+
236
+
237
+ def _nonce_generation(truncated_hashed_sk: bytes, h_point: Point) -> int:
238
+ """ECVRF_nonce_generation_RFC8032 (§5.4.2.2)."""
239
+ k_string = _sha512(truncated_hashed_sk, point_encode(h_point))
240
+ return string_to_int(k_string) % L
241
+
242
+
243
+ def _gamma_to_hash(gamma: Point) -> bytes:
244
+ """ECVRF_proof_to_hash beta = SHA512(suite||0x03||encode(cofactor*Gamma)||0x00)."""
245
+ cofactor_gamma = scalar_mul(COFACTOR, gamma)
246
+ return _sha512(SUITE, b"\x03", point_encode(cofactor_gamma), b"\x00")
247
+
248
+
249
+ def prove(sk: bytes, alpha: bytes) -> bytes:
250
+ """ECVRF_prove (§5.1): return the 80-byte proof pi for (SK, alpha)."""
251
+ x, truncated_hashed_sk, pk = expand_sk(sk)
252
+ base = _base_point()
253
+ h = hash_to_curve_tai(pk, alpha)
254
+ gamma = scalar_mul(x, h)
255
+ k = _nonce_generation(truncated_hashed_sk, h)
256
+ u = scalar_mul(k, base) # k*B
257
+ v = scalar_mul(k, h) # k*H
258
+ y = scalar_mul(x, base) # public-key point Y = x*B
259
+ # RFC 9381 §5.1 step 6: c = challenge_generation(Y, H, Gamma, k*B, k*H).
260
+ # The public key Y MUST be the first transcript point — omitting it yields
261
+ # proofs a conformant RFC-9381 verifier would reject (output beta is the same,
262
+ # but interop breaks), which would defeat the whole point of a standard VRF.
263
+ c = _challenge_generation([y, h, gamma, u, v])
264
+ s = (k + c * x) % L
265
+ return point_encode(gamma) + int_to_string(c, C_LEN) + int_to_string(s, Q_LEN)
266
+
267
+
268
+ def proof_to_hash(pi: bytes) -> bytes | None:
269
+ """ECVRF_proof_to_hash (§5.2): decode pi and return the 64-byte beta (or None)."""
270
+ decoded = decode_proof(pi)
271
+ if decoded is None:
272
+ return None
273
+ gamma, _c, _s = decoded
274
+ return _gamma_to_hash(gamma)
275
+
276
+
277
+ def decode_proof(pi: bytes) -> Tuple[Point, int, int] | None:
278
+ """ECVRF_decode_proof (§5.4.4): pi (80 bytes) -> (Gamma, c, s) or None if malformed."""
279
+ if len(pi) != PT_LEN + C_LEN + Q_LEN:
280
+ return None
281
+ gamma = point_decode(pi[0:PT_LEN])
282
+ if gamma is None:
283
+ return None
284
+ c = string_to_int(pi[PT_LEN:PT_LEN + C_LEN])
285
+ s = string_to_int(pi[PT_LEN + C_LEN:PT_LEN + C_LEN + Q_LEN])
286
+ if s >= L: # scalar must be reduced (non-malleable)
287
+ return None
288
+ return gamma, c, s
289
+
290
+
291
+ def verify(pk: bytes, alpha: bytes, pi: bytes) -> bytes | None:
292
+ """ECVRF_verify (§5.3): return beta (64 bytes) iff pi is valid for (PK, alpha), else None."""
293
+ y = point_decode(pk)
294
+ if y is None:
295
+ return None
296
+ decoded = decode_proof(pi)
297
+ if decoded is None:
298
+ return None
299
+ gamma, c, s = decoded
300
+ base = _base_point()
301
+ h = hash_to_curve_tai(pk, alpha)
302
+ # U = s*B - c*Y ; V = s*H - c*Gamma
303
+ u = point_add(scalar_mul(s, base), _negate(scalar_mul(c, y)))
304
+ v = point_add(scalar_mul(s, h), _negate(scalar_mul(c, gamma)))
305
+ # RFC 9381 §5.3 step 10: c' = challenge_generation(Y, H, Gamma, U, V).
306
+ c_prime = _challenge_generation([y, h, gamma, u, v])
307
+ if c_prime != c:
308
+ return None
309
+ return _gamma_to_hash(gamma)
310
+
311
+
312
+ def _negate(pt: Point) -> Point:
313
+ x, y, z, t = pt
314
+ return ((-x) % P, y, z, (-t) % P)
315
+
316
+
317
+ # --- output stretching ------------------------------------------------------
318
+ def expand_output(beta: bytes, num_bytes: int) -> bytes:
319
+ """Derive `num_bytes` of uniform output from the 64-byte beta.
320
+
321
+ For num_bytes <= 64 this is a prefix of beta; for larger requests beta is
322
+ used as a key in a SHA512 counter-mode stream so the result stays uniform
323
+ and fully determined by beta (hence still verifiable & ungrindable).
324
+ """
325
+ if num_bytes <= len(beta):
326
+ return beta[:num_bytes]
327
+ out = bytearray()
328
+ ctr = 0
329
+ while len(out) < num_bytes:
330
+ out += _sha512(beta, int_to_string(ctr, 4))
331
+ ctr += 1
332
+ return bytes(out[:num_bytes])
@@ -0,0 +1,314 @@
1
+ """Sortes tests — the RFC 9381 ECVRF-EDWARDS25519-SHA512-TAI test vectors are the
2
+ decisive correctness proof. If our edwards25519 + ECVRF implementation reproduces
3
+ the published (SK->PK) and (SK,alpha)->pi->beta bytes exactly, it is correct."""
4
+
5
+ import json
6
+
7
+ import pytest
8
+ from httpx import ASGITransport, AsyncClient
9
+
10
+ from sortes import vrf
11
+ from sortes.capabilities import PUBLIC_KEY_HEX
12
+ from sortes.main import app
13
+
14
+ # --- RFC 9381 Appendix B.3: ECVRF-EDWARDS25519-SHA512-TAI test vectors ------
15
+ # (suite_string = 0x03). Each tuple: (SK, PK, alpha_bytes, H, gamma, beta).
16
+ #
17
+ # These are the values published in RFC 9381 Appendix B.3. The decisive anchors
18
+ # are SK->PK, the hash_to_curve point H, the proof's binding commitment Gamma
19
+ # (== the first 32 bytes of the published pi), and beta (the VRF *output*, which
20
+ # is SHA512(0x03||0x03||encode(8*Gamma)||0x00) — i.e. fully determined by Gamma).
21
+ # If an implementation reproduces PK, H, Gamma AND beta for all three vectors and
22
+ # its own proofs verify, it is a correct ECVRF-EDWARDS25519-SHA512-TAI per RFC 9381:
23
+ # a wrong scalar/challenge could not yield the correct Gamma together with a
24
+ # self-consistent verify. We also pin the full 80-byte pi we emit as a regression
25
+ # anchor (the c||s tail is exercised end-to-end by the verify roundtrip below).
26
+ RFC9381_TAI_VECTORS = [
27
+ (
28
+ "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
29
+ "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
30
+ b"", # empty alpha
31
+ "91bbed02a99461df1ad4c6564a5f5d829d0b90cfc7903e7a5797bd658abf3318", # H
32
+ "8657106690b5526245a92b003bb079ccd1a92130477671f6fc01ad16f26f723f", # Gamma
33
+ "90cf1df3b703cce59e2a35b925d411164068269d7b2d29f3301c03dd757876ff"
34
+ "66b71dda49d2de59d03450451af026798e8f81cd2e333de5cdf4f3e140fdd8ae", # beta
35
+ ),
36
+ (
37
+ "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
38
+ "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
39
+ bytes([0x72]), # alpha = 0x72
40
+ "5b659fc3d4e9263fd9a4ed1d022d75eaacc20df5e09f9ea937502396598dc551", # H
41
+ "f3141cd382dc42909d19ec5110469e4feae18300e94f304590abdced48aed593", # Gamma
42
+ "eb4440665d3891d668e7e0fcaf587f1b4bd7fbfe99d0eb2211ccec90496310eb"
43
+ "5e33821bc613efb94db5e5b54c70a848a0bef4553a41befc57663b56373a5031", # beta
44
+ ),
45
+ (
46
+ "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
47
+ "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
48
+ bytes([0xAF, 0x82]), # alpha = 0xaf82
49
+ "bf4339376f5542811de615e3313d2b36f6f53c0acfebb482159711201192576a", # H
50
+ "9bc0f79119cc5604bf02d23b4caede71393cedfbb191434dd016d30177ccbf80", # Gamma
51
+ "645427e5d00c62a23fb703732fa5d892940935942101e456ecca7bb217c61c45"
52
+ "2118fec1219202a0edcf038bb6373241578be7217ba85a2687f7a0310b2df19f", # beta
53
+ ),
54
+ ]
55
+
56
+
57
+ # Full 80-byte proofs pi from the SAME RFC 9381 Appendix B.3 examples (16, 17, 18).
58
+ # Unlike H/Gamma/beta (which are independent of the c||s encoding), pi pins the
59
+ # challenge c and scalar s — the exact bytes a *conformant* ECVRF verifier
60
+ # (e.g. libsodium / the CFRG reference) checks. These match ONLY when
61
+ # int_to_string/string_to_int use the RFC-mandated little-endian convention
62
+ # (RFC 9381 §5.5). A big-endian implementation stays self-consistent and still
63
+ # reproduces Gamma and beta, but emits a byte-reversed c||s tail, so this test
64
+ # FAILS for it — that is the byte-interoperability regression guard.
65
+ RFC9381_TAI_PI = [
66
+ ("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", b"",
67
+ "8657106690b5526245a92b003bb079ccd1a92130477671f6fc01ad16f26f723f"
68
+ "26f8a57ccaed74ee1b190bed1f479d9727d2d0f9b005a6e456a35d4fb0daab126"
69
+ "8a1b0db10836d9826a528ca76567805"),
70
+ ("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", bytes([0x72]),
71
+ "f3141cd382dc42909d19ec5110469e4feae18300e94f304590abdced48aed593"
72
+ "3bf0864a62558b3ed7f2fea45c92a465301b3bbf5e3e54ddf2d935be3b67926d"
73
+ "a3ef39226bbc355bdc9850112c8f4b02"),
74
+ ("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", bytes([0xAF, 0x82]),
75
+ "9bc0f79119cc5604bf02d23b4caede71393cedfbb191434dd016d30177ccbf80"
76
+ "96bb474e53895c362d8628ee9f9ea3c0e52c7a5c691b6c18c9979866568add7a"
77
+ "2d41b00b05081ed0f58ee5e31b3a970e"),
78
+ ]
79
+
80
+
81
+ class TestRFC9381FullProof:
82
+ """Byte-interoperability: the full 80-byte pi must match RFC 9381 Appendix B.3."""
83
+
84
+ @pytest.mark.parametrize("sk,alpha,pi", RFC9381_TAI_PI)
85
+ def test_prove_full_pi_matches_rfc(self, sk, alpha, pi):
86
+ # The decisive byte-conformance check. Fails under a big-endian c||s
87
+ # encoding even though Gamma/beta still match; passes only for the
88
+ # RFC-mandated little-endian encoding (RFC 9381 §5.5).
89
+ assert vrf.prove(bytes.fromhex(sk), alpha).hex() == pi
90
+
91
+ @pytest.mark.parametrize("sk,alpha,pi", RFC9381_TAI_PI)
92
+ def test_rfc_reference_pi_verifies(self, sk, alpha, pi):
93
+ # The proof bytes as published by the RFC must verify under our verifier
94
+ # (i.e. our verifier is conformant, not merely self-consistent).
95
+ pk = vrf.sk_to_pk(bytes.fromhex(sk))
96
+ assert vrf.verify(pk, alpha, bytes.fromhex(pi)) is not None
97
+
98
+
99
+ class TestRFC9381Vectors:
100
+ @pytest.mark.parametrize("sk,pk,alpha,h,gamma,beta", RFC9381_TAI_VECTORS)
101
+ def test_sk_to_pk(self, sk, pk, alpha, h, gamma, beta):
102
+ assert vrf.sk_to_pk(bytes.fromhex(sk)).hex() == pk
103
+
104
+ @pytest.mark.parametrize("sk,pk,alpha,h,gamma,beta", RFC9381_TAI_VECTORS)
105
+ def test_hash_to_curve_matches_H(self, sk, pk, alpha, h, gamma, beta):
106
+ H = vrf.hash_to_curve_tai(bytes.fromhex(pk), alpha)
107
+ assert vrf.point_encode(H).hex() == h
108
+
109
+ @pytest.mark.parametrize("sk,pk,alpha,h,gamma,beta", RFC9381_TAI_VECTORS)
110
+ def test_prove_gamma_matches(self, sk, pk, alpha, h, gamma, beta):
111
+ # Gamma is the first 32 bytes of pi and binds the output to (PK, alpha).
112
+ pi = vrf.prove(bytes.fromhex(sk), alpha)
113
+ assert pi[:32].hex() == gamma
114
+
115
+ @pytest.mark.parametrize("sk,pk,alpha,h,gamma,beta", RFC9381_TAI_VECTORS)
116
+ def test_prove_beta_matches(self, sk, pk, alpha, h, gamma, beta):
117
+ # beta is the actual VRF output (SHA512 over 8*Gamma) — the decisive bytes.
118
+ pi = vrf.prove(bytes.fromhex(sk), alpha)
119
+ assert vrf.proof_to_hash(pi).hex() == beta
120
+
121
+ @pytest.mark.parametrize("sk,pk,alpha,h,gamma,beta", RFC9381_TAI_VECTORS)
122
+ def test_own_proof_verifies_to_beta(self, sk, pk, alpha, h, gamma, beta):
123
+ pi = vrf.prove(bytes.fromhex(sk), alpha)
124
+ out = vrf.verify(bytes.fromhex(pk), alpha, pi)
125
+ assert out is not None and out.hex() == beta
126
+
127
+
128
+ class TestVRFRoundtrip:
129
+ SK = bytes.fromhex("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7")
130
+
131
+ def test_draw_verify_roundtrip(self):
132
+ pk = vrf.sk_to_pk(self.SK)
133
+ alpha = b"draw-for-agent-42"
134
+ pi = vrf.prove(self.SK, alpha)
135
+ beta = vrf.verify(pk, alpha, pi)
136
+ assert beta is not None
137
+ assert beta == vrf.proof_to_hash(pi)
138
+
139
+ def test_tampered_pi_rejected(self):
140
+ pk = vrf.sk_to_pk(self.SK)
141
+ alpha = b"lottery-round-7"
142
+ pi = bytearray(vrf.prove(self.SK, alpha))
143
+ pi[40] ^= 0x01 # flip a bit inside c
144
+ assert vrf.verify(pk, alpha, bytes(pi)) is None
145
+
146
+ def test_wrong_alpha_rejected(self):
147
+ pk = vrf.sk_to_pk(self.SK)
148
+ pi = vrf.prove(self.SK, b"alpha-A")
149
+ assert vrf.verify(pk, b"alpha-B", pi) is None
150
+
151
+ def test_wrong_pk_rejected(self):
152
+ other_pk = vrf.sk_to_pk(bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"))
153
+ alpha = b"bound-to-the-key"
154
+ pi = vrf.prove(self.SK, alpha)
155
+ assert vrf.verify(other_pk, alpha, pi) is None
156
+
157
+ def test_scalar_s_out_of_range_rejected(self):
158
+ # s >= L must be rejected by decode_proof (non-malleability).
159
+ pk = vrf.sk_to_pk(self.SK)
160
+ alpha = b"x"
161
+ pi = bytearray(vrf.prove(self.SK, alpha))
162
+ # s is little-endian per RFC 9381 §5.5; encode L+1 the same way so this
163
+ # genuinely sets an out-of-range scalar that decode_proof must reject.
164
+ pi[48:80] = (vrf.L + 1).to_bytes(32, "little")
165
+ assert vrf.verify(pk, alpha, bytes(pi)) is None
166
+
167
+ def test_ungrindable_determinism(self):
168
+ # The VRF is a function: same (SK, alpha) -> identical pi & beta every time.
169
+ a = vrf.prove(self.SK, b"same-input")
170
+ b = vrf.prove(self.SK, b"same-input")
171
+ assert a == b
172
+ assert vrf.prove(self.SK, b"other-input") != a
173
+
174
+ def test_output_stretch_is_deterministic(self):
175
+ beta = bytes(range(64))
176
+ # up to 64 bytes: a prefix of beta
177
+ assert vrf.expand_output(beta, 16) == beta[:16]
178
+ assert vrf.expand_output(beta, 64) == beta
179
+ # beyond 64: a deterministic SHA512 counter stream keyed on beta
180
+ long1 = vrf.expand_output(beta, 100)
181
+ long2 = vrf.expand_output(beta, 100)
182
+ assert len(long1) == 100 and long1 == long2
183
+ # different beta -> different stretch (ungrindable, still bound to beta)
184
+ assert vrf.expand_output(bytes(range(1, 65)), 100) != long1
185
+
186
+
187
+ class TestPointArithmetic:
188
+ def test_base_point_on_curve(self):
189
+ assert vrf.is_on_curve(vrf._base_point())
190
+
191
+ def test_encode_decode_roundtrip(self):
192
+ pk = vrf.sk_to_pk(bytes.fromhex("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"))
193
+ pt = vrf.point_decode(pk)
194
+ assert pt is not None and vrf.is_on_curve(pt)
195
+ assert vrf.point_encode(pt) == pk
196
+
197
+ def test_scalar_mul_order(self):
198
+ # L * B == identity
199
+ ident = vrf.scalar_mul(vrf.L, vrf._base_point())
200
+ assert vrf.point_equal(ident, (0, 1, 1, 0))
201
+
202
+
203
+ class TestSortesApp:
204
+ @pytest.mark.asyncio
205
+ async def test_draw_then_verify_via_invoke(self):
206
+ transport = ASGITransport(app=app)
207
+ async with AsyncClient(transport=transport, base_url="http://t") as c:
208
+ dr = (await c.post("/ai-market/v2/invoke", json={
209
+ "capability_id": "sortes.draw@v1",
210
+ "input": {"alpha": "agent-seed-1", "num_bytes": 32},
211
+ })).json()
212
+ assert dr["ok"] is True
213
+ out = dr["output"]
214
+ assert out["suite"] == "ECVRF-EDWARDS25519-SHA512-TAI"
215
+ assert out["public_key"] == PUBLIC_KEY_HEX
216
+ assert len(out["pi"]) == 160 # 80 bytes hex
217
+ assert len(out["output"]) == 64 # 32 bytes hex
218
+ assert "sk" not in out # the secret key is NEVER returned
219
+
220
+ vr = (await c.post("/ai-market/v2/invoke", json={
221
+ "capability_id": "sortes.verify@v1",
222
+ "input": {"public_key": out["public_key"], "alpha": "agent-seed-1", "pi": out["pi"]},
223
+ })).json()
224
+ assert vr["ok"] is True
225
+ assert vr["output"]["valid"] is True
226
+ assert vr["output"]["beta"] == out["beta"]
227
+
228
+ @pytest.mark.asyncio
229
+ async def test_verify_rejects_tampered_via_invoke(self):
230
+ transport = ASGITransport(app=app)
231
+ async with AsyncClient(transport=transport, base_url="http://t") as c:
232
+ dr = (await c.post("/ai-market/v2/invoke", json={
233
+ "capability_id": "sortes.draw@v1", "input": {"alpha": "x"},
234
+ })).json()
235
+ out = dr["output"]
236
+ bad = "00" + out["pi"][2:] # corrupt first byte
237
+ vr = (await c.post("/ai-market/v2/invoke", json={
238
+ "capability_id": "sortes.verify@v1",
239
+ "input": {"public_key": out["public_key"], "alpha": "x", "pi": bad},
240
+ })).json()
241
+ assert vr["ok"] is True
242
+ assert vr["output"]["valid"] is False
243
+ assert vr["output"]["beta"] is None
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_caller_supplied_sk_is_refused_by_default(self, monkeypatch):
247
+ """A draw under a key the CALLER chose is worthless, so it must be refused.
248
+
249
+ `sk` is not in the published input_schema and oracle_core does not validate
250
+ input against the schema, so this handler is the only gate. Without it any
251
+ caller gets a well-formed draw response echoing their own `public_key` —
252
+ which makes any certificate that trusts the response's `public_key`
253
+ forgeable by an entrant. Default-off, opt-in for RFC-vector reproduction.
254
+ """
255
+ monkeypatch.delenv("SORTES_ALLOW_CALLER_SK", raising=False)
256
+ transport = ASGITransport(app=app)
257
+ async with AsyncClient(transport=transport, base_url="http://t") as c:
258
+ dr = (await c.post("/ai-market/v2/invoke", json={
259
+ "capability_id": "sortes.draw@v1",
260
+ "input": {
261
+ "alpha": "winner-draw-2026",
262
+ "sk": "01" * 32,
263
+ },
264
+ })).json()
265
+ # The attacker's key must never appear, and the oracle must not have drawn.
266
+ body = json.dumps(dr)
267
+ assert vrf.sk_to_pk(bytes.fromhex("01" * 32)).hex() not in body
268
+ assert dr.get("ok") is not True
269
+
270
+ @pytest.mark.asyncio
271
+ async def test_draw_with_dev_sk_reproduces_rfc_vector(self, monkeypatch):
272
+ # The dev `sk` passthrough lets a caller reproduce a published RFC vector.
273
+ # We pin the public key, the binding commitment gamma, and the VRF output
274
+ # beta — all RFC 9381 Appendix B.3 (Example 18) values.
275
+ monkeypatch.setenv("SORTES_ALLOW_CALLER_SK", "1")
276
+ transport = ASGITransport(app=app)
277
+ async with AsyncClient(transport=transport, base_url="http://t") as c:
278
+ dr = (await c.post("/ai-market/v2/invoke", json={
279
+ "capability_id": "sortes.draw@v1",
280
+ "input": {"alpha": "hex:af82",
281
+ "sk": "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7"},
282
+ })).json()
283
+ out = dr["output"]
284
+ assert out["public_key"] == "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"
285
+ assert out["gamma"] == "9bc0f79119cc5604bf02d23b4caede71393cedfbb191434dd016d30177ccbf80"
286
+ assert out["beta"] == (
287
+ "645427e5d00c62a23fb703732fa5d892940935942101e456ecca7bb217c61c45"
288
+ "2118fec1219202a0edcf038bb6373241578be7217ba85a2687f7a0310b2df19f"
289
+ )
290
+ # the proof the oracle emits must verify offline (full pi exercised here)
291
+ vr = (await c.post("/ai-market/v2/invoke", json={
292
+ "capability_id": "sortes.verify@v1",
293
+ "input": {"public_key": out["public_key"], "alpha": "hex:af82", "pi": out["pi"]},
294
+ })).json()
295
+ assert vr["output"]["valid"] is True and vr["output"]["beta"] == out["beta"]
296
+
297
+ @pytest.mark.asyncio
298
+ async def test_missing_alpha_errors(self):
299
+ transport = ASGITransport(app=app)
300
+ async with AsyncClient(transport=transport, base_url="http://t") as c:
301
+ dr = (await c.post("/ai-market/v2/invoke", json={
302
+ "capability_id": "sortes.draw@v1", "input": {},
303
+ })).json()
304
+ assert dr["ok"] is False
305
+ assert "alpha" in dr["error"]
306
+
307
+ @pytest.mark.asyncio
308
+ async def test_manifest_signed(self):
309
+ transport = ASGITransport(app=app)
310
+ async with AsyncClient(transport=transport, base_url="http://t") as c:
311
+ m = (await c.get("/ai-market/v2/manifest")).json()
312
+ ids = {t["capability_id"] for t in m["tools"]}
313
+ assert ids == {"sortes.draw@v1", "sortes.verify@v1"}
314
+ assert app.state.protocol.signer.verify_manifest_signature(m) is True
@@ -0,0 +1,38 @@
1
+
2
+
3
+ # ── draw → verify must round-trip ────────────────────────────────
4
+ # `draw` emitted `alpha` as a bare hex string; `_alpha_to_bytes` only decodes hex behind a
5
+ # `hex:` prefix, so verify UTF-8 encoded those hex characters and hashed 18 bytes where the
6
+ # prover had hashed 9. Every honest proof the oracle issued verified as false — a paid
7
+ # verification capability that could not confirm its own output.
8
+
9
+ from sortes import capabilities as C
10
+
11
+
12
+ def test_draw_output_verifies_as_issued():
13
+ d = C._draw({"alpha": "test-seed"})
14
+ out = C._verify({"public_key": d["public_key"], "alpha": d["alpha"], "pi": d["pi"]})
15
+ assert out["valid"] is True, "the oracle cannot verify a proof it just produced"
16
+ assert out["beta"] == d["beta"]
17
+
18
+
19
+ def test_the_original_seed_also_verifies():
20
+ """A caller who kept their own seed string, rather than the echoed alpha, must not be
21
+ told their valid proof is invalid either."""
22
+ d = C._draw({"alpha": "test-seed"})
23
+ assert C._verify({"public_key": d["public_key"], "alpha": "test-seed", "pi": d["pi"]})["valid"]
24
+
25
+
26
+ def test_a_hex_looking_seed_stays_text():
27
+ """Decoding by shape would make the wire format ambiguous: 'deadbeef' as a seed means
28
+ eight characters, and the proof binds to exact bytes."""
29
+ assert C._alpha_to_bytes("deadbeef") == b"deadbeef"
30
+ assert C._alpha_to_bytes("hex:deadbeef") == bytes.fromhex("deadbeef")
31
+
32
+
33
+ def test_forgeries_still_fail():
34
+ d = C._draw({"alpha": "test-seed"})
35
+ pk, pi = d["public_key"], d["pi"]
36
+ assert not C._verify({"public_key": pk, "alpha": "hex:00", "pi": pi})["valid"]
37
+ assert not C._verify({"public_key": pk, "alpha": d["alpha"], "pi": "00" * 40})["valid"]
38
+ assert not C._verify({"public_key": "00" * 32, "alpha": d["alpha"], "pi": pi})["valid"]