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.
- aimarket_oracle_sortes-0.1.0/.gitignore +27 -0
- aimarket_oracle_sortes-0.1.0/PKG-INFO +77 -0
- aimarket_oracle_sortes-0.1.0/README.md +64 -0
- aimarket_oracle_sortes-0.1.0/pyproject.toml +55 -0
- aimarket_oracle_sortes-0.1.0/sortes/__init__.py +1 -0
- aimarket_oracle_sortes-0.1.0/sortes/capabilities.py +260 -0
- aimarket_oracle_sortes-0.1.0/sortes/main.py +21 -0
- aimarket_oracle_sortes-0.1.0/sortes/vrf.py +332 -0
- aimarket_oracle_sortes-0.1.0/tests/test_sortes.py +314 -0
- aimarket_oracle_sortes-0.1.0/tests/test_vrf.py +38 -0
|
@@ -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"]
|