parmana-verifier 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.
- parmana_verifier-0.1.0/PKG-INFO +84 -0
- parmana_verifier-0.1.0/README.md +72 -0
- parmana_verifier-0.1.0/parmana_verifier/__init__.py +22 -0
- parmana_verifier-0.1.0/parmana_verifier/__main__.py +5 -0
- parmana_verifier-0.1.0/parmana_verifier/canonical.py +86 -0
- parmana_verifier-0.1.0/parmana_verifier/cli.py +68 -0
- parmana_verifier-0.1.0/parmana_verifier/timestamps.py +54 -0
- parmana_verifier-0.1.0/parmana_verifier/trust.py +116 -0
- parmana_verifier-0.1.0/parmana_verifier/verify.py +283 -0
- parmana_verifier-0.1.0/parmana_verifier.egg-info/PKG-INFO +84 -0
- parmana_verifier-0.1.0/parmana_verifier.egg-info/SOURCES.txt +16 -0
- parmana_verifier-0.1.0/parmana_verifier.egg-info/dependency_links.txt +1 -0
- parmana_verifier-0.1.0/parmana_verifier.egg-info/entry_points.txt +2 -0
- parmana_verifier-0.1.0/parmana_verifier.egg-info/requires.txt +4 -0
- parmana_verifier-0.1.0/parmana_verifier.egg-info/top_level.txt +1 -0
- parmana_verifier-0.1.0/pyproject.toml +24 -0
- parmana_verifier-0.1.0/setup.cfg +4 -0
- parmana_verifier-0.1.0/tests/test_verifier.py +291 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parmana-verifier
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline verifier for Parmana Execution Trust Records (Python).
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Repository, https://github.com/pavancharak/parmana-verifier
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: cryptography>=42
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
12
|
+
|
|
13
|
+
# parmana-verifier (Python)
|
|
14
|
+
|
|
15
|
+
Check whether a Parmana Execution Trust Record was signed by a key you trust, using only the record and a trust file. No network access, no account, no Parmana server needed.
|
|
16
|
+
|
|
17
|
+
This is the Python counterpart of the Node verifier in the repository root. It checks the same things in the same order, and it returns the same result shape and exit codes. Its results match the Node verifier on every test vector and on records generated by each implementation.
|
|
18
|
+
|
|
19
|
+
## What it checks, and what it does not
|
|
20
|
+
|
|
21
|
+
**Checked**
|
|
22
|
+
|
|
23
|
+
- The record hash (`trustRecordHash`) against the canonical record fields.
|
|
24
|
+
- The Ed25519 signature, over the legacy message, from a key in your trust file.
|
|
25
|
+
- Ed25519 signatures in a hybrid `signatures` array, and the whole-record (schema version 3) message.
|
|
26
|
+
- Trust rules: the key's fingerprint, its validity window, and the `compromised` flag.
|
|
27
|
+
- The `hybridRequiredFrom` cutover, when the trust file sets one.
|
|
28
|
+
|
|
29
|
+
**Not checked: ML-DSA-65 (`dilithium3`) signatures.** The `cryptography` package does not provide ML-DSA. A record with a `dilithium3` entry is reported as invalid, so this verifier never accepts a hybrid record on its Ed25519 part alone. To check the ML-DSA-65 part, use the Node verifier (`@parmana/verifier`) on Node 24 or later with OpenSSL 3.5 or later.
|
|
30
|
+
|
|
31
|
+
**Other differences from the Node verifier**
|
|
32
|
+
|
|
33
|
+
- A timestamp without a time zone offset is rejected. Node reads it in the local time zone, so this verifier gives the same answer on every machine.
|
|
34
|
+
- Integers beyond 2^53 are rounded the way JavaScript rounds them when it parses JSON. Use records written by Parmana, which never contain such integers.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Requires Python 3.11 or later (tested on 3.11, 3.12 and 3.13). The only dependency is `cryptography` (version 42 or later). It is not yet published to PyPI, so install from a clone of this repository:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
cd python
|
|
42
|
+
pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For the tests, install the `test` extra as well: `pip install -e ".[test]"`.
|
|
46
|
+
|
|
47
|
+
## Command line
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
parmana-verify <proof.json> <trust.json>
|
|
51
|
+
parmana-verify key-fingerprint <public-key.pem>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `verify` prints one line of JSON and exits `0` when the record is valid, `1` when it is invalid, and `2` for a usage or read error.
|
|
55
|
+
- `key-fingerprint` prints the `sha256:...` value to pin in a trust file.
|
|
56
|
+
|
|
57
|
+
The output has the same fields as the Node CLI: `valid`, `coverage`, `errors` and `checks`.
|
|
58
|
+
|
|
59
|
+
## Python API
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import json
|
|
63
|
+
from parmana_verifier import parse_trust_policy, verify_execution_trust_record
|
|
64
|
+
|
|
65
|
+
record = json.load(open("proof.json"))
|
|
66
|
+
policy = parse_trust_policy(json.load(open("trust.json")))
|
|
67
|
+
|
|
68
|
+
result = verify_execution_trust_record(record, policy.keys, policy.hybrid_required_from)
|
|
69
|
+
print(result["valid"], result["errors"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Trust file
|
|
73
|
+
|
|
74
|
+
The trust file lists each key you accept, pinned by its fingerprint. See the [main README](../README.md#installation-and-usage) for the format. The verifier never fetches keys.
|
|
75
|
+
|
|
76
|
+
## Tests
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
cd python
|
|
80
|
+
pip install -e ".[test]"
|
|
81
|
+
pytest
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The tests run the shared test vectors in `../test/vectors`, check the canonical serialization against JavaScript's number and string formatting, and generate Ed25519 records at test time to cover the trust rules and the CLI.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# parmana-verifier (Python)
|
|
2
|
+
|
|
3
|
+
Check whether a Parmana Execution Trust Record was signed by a key you trust, using only the record and a trust file. No network access, no account, no Parmana server needed.
|
|
4
|
+
|
|
5
|
+
This is the Python counterpart of the Node verifier in the repository root. It checks the same things in the same order, and it returns the same result shape and exit codes. Its results match the Node verifier on every test vector and on records generated by each implementation.
|
|
6
|
+
|
|
7
|
+
## What it checks, and what it does not
|
|
8
|
+
|
|
9
|
+
**Checked**
|
|
10
|
+
|
|
11
|
+
- The record hash (`trustRecordHash`) against the canonical record fields.
|
|
12
|
+
- The Ed25519 signature, over the legacy message, from a key in your trust file.
|
|
13
|
+
- Ed25519 signatures in a hybrid `signatures` array, and the whole-record (schema version 3) message.
|
|
14
|
+
- Trust rules: the key's fingerprint, its validity window, and the `compromised` flag.
|
|
15
|
+
- The `hybridRequiredFrom` cutover, when the trust file sets one.
|
|
16
|
+
|
|
17
|
+
**Not checked: ML-DSA-65 (`dilithium3`) signatures.** The `cryptography` package does not provide ML-DSA. A record with a `dilithium3` entry is reported as invalid, so this verifier never accepts a hybrid record on its Ed25519 part alone. To check the ML-DSA-65 part, use the Node verifier (`@parmana/verifier`) on Node 24 or later with OpenSSL 3.5 or later.
|
|
18
|
+
|
|
19
|
+
**Other differences from the Node verifier**
|
|
20
|
+
|
|
21
|
+
- A timestamp without a time zone offset is rejected. Node reads it in the local time zone, so this verifier gives the same answer on every machine.
|
|
22
|
+
- Integers beyond 2^53 are rounded the way JavaScript rounds them when it parses JSON. Use records written by Parmana, which never contain such integers.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
Requires Python 3.11 or later (tested on 3.11, 3.12 and 3.13). The only dependency is `cryptography` (version 42 or later). It is not yet published to PyPI, so install from a clone of this repository:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
cd python
|
|
30
|
+
pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For the tests, install the `test` extra as well: `pip install -e ".[test]"`.
|
|
34
|
+
|
|
35
|
+
## Command line
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
parmana-verify <proof.json> <trust.json>
|
|
39
|
+
parmana-verify key-fingerprint <public-key.pem>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `verify` prints one line of JSON and exits `0` when the record is valid, `1` when it is invalid, and `2` for a usage or read error.
|
|
43
|
+
- `key-fingerprint` prints the `sha256:...` value to pin in a trust file.
|
|
44
|
+
|
|
45
|
+
The output has the same fields as the Node CLI: `valid`, `coverage`, `errors` and `checks`.
|
|
46
|
+
|
|
47
|
+
## Python API
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import json
|
|
51
|
+
from parmana_verifier import parse_trust_policy, verify_execution_trust_record
|
|
52
|
+
|
|
53
|
+
record = json.load(open("proof.json"))
|
|
54
|
+
policy = parse_trust_policy(json.load(open("trust.json")))
|
|
55
|
+
|
|
56
|
+
result = verify_execution_trust_record(record, policy.keys, policy.hybrid_required_from)
|
|
57
|
+
print(result["valid"], result["errors"])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Trust file
|
|
61
|
+
|
|
62
|
+
The trust file lists each key you accept, pinned by its fingerprint. See the [main README](../README.md#installation-and-usage) for the format. The verifier never fetches keys.
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
cd python
|
|
68
|
+
pip install -e ".[test]"
|
|
69
|
+
pytest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The tests run the shared test vectors in `../test/vectors`, check the canonical serialization against JavaScript's number and string formatting, and generate Ed25519 records at test time to cover the trust rules and the CLI.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Offline verifier for Parmana Execution Trust Records (Python).
|
|
2
|
+
|
|
3
|
+
Python counterpart to the Node verifier in the repository root. It checks the
|
|
4
|
+
record hash and Ed25519 signatures against the keys you pin in a trust file,
|
|
5
|
+
and makes no network calls. It does not check ML-DSA-65 (dilithium3)
|
|
6
|
+
signatures; see README.md.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .canonical import canonical_bytes
|
|
10
|
+
from .trust import TrustEntry, TrustPolicy, key_fingerprint, parse_trust_policy
|
|
11
|
+
from .verify import verify_execution_trust_record
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"TrustEntry",
|
|
15
|
+
"TrustPolicy",
|
|
16
|
+
"canonical_bytes",
|
|
17
|
+
"key_fingerprint",
|
|
18
|
+
"parse_trust_policy",
|
|
19
|
+
"verify_execution_trust_record",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Canonical serialization for Parmana artifacts.
|
|
2
|
+
|
|
3
|
+
Mirrors src/canonical.mjs. Object keys are sorted in UTF-16 code-unit order,
|
|
4
|
+
arrays keep their order, and numbers and strings are written the way
|
|
5
|
+
JavaScript's JSON.stringify writes them. Every hash and signature is computed
|
|
6
|
+
over these bytes, so the verifier must produce exactly the same bytes as the
|
|
7
|
+
signer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
|
|
14
|
+
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
15
|
+
_MAX_SAFE_INTEGER = 2**53 - 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def canonical_bytes(value):
|
|
19
|
+
return _dump(value).encode("utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _dump(value):
|
|
23
|
+
if value is None:
|
|
24
|
+
return "null"
|
|
25
|
+
if value is True:
|
|
26
|
+
return "true"
|
|
27
|
+
if value is False:
|
|
28
|
+
return "false"
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
return _string(value)
|
|
31
|
+
if isinstance(value, int):
|
|
32
|
+
# JSON.parse rounds integers beyond 2**53 to the nearest double, so the
|
|
33
|
+
# signer's bytes contain that rounded value, not the exact integer.
|
|
34
|
+
if abs(value) > _MAX_SAFE_INTEGER:
|
|
35
|
+
return js_number(float(value))
|
|
36
|
+
return str(value)
|
|
37
|
+
if isinstance(value, float):
|
|
38
|
+
return js_number(value)
|
|
39
|
+
if isinstance(value, list):
|
|
40
|
+
return "[" + ",".join(_dump(item) for item in value) + "]"
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
keys = sorted(value, key=_utf16_sort_key)
|
|
43
|
+
return "{" + ",".join(_string(key) + ":" + _dump(value[key]) for key in keys) + "}"
|
|
44
|
+
raise TypeError(f"cannot canonicalize value of type {type(value).__name__}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _utf16_sort_key(key):
|
|
48
|
+
return key.encode("utf-16-be", "surrogatepass")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _string(value):
|
|
52
|
+
text = json.dumps(value, ensure_ascii=False)
|
|
53
|
+
# JSON.stringify escapes an unpaired surrogate as \uXXXX (lowercase). Python
|
|
54
|
+
# keeps it as a bare code point, which cannot be encoded as UTF-8.
|
|
55
|
+
return _LONE_SURROGATE.sub(lambda match: "\\u%04x" % ord(match.group(0)), text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def js_number(number):
|
|
59
|
+
"""Format a finite double the way ECMAScript Number.prototype.toString does."""
|
|
60
|
+
if number != number or number in (float("inf"), float("-inf")):
|
|
61
|
+
return "null"
|
|
62
|
+
if number == 0:
|
|
63
|
+
return "0"
|
|
64
|
+
if number < 0:
|
|
65
|
+
return "-" + js_number(-number)
|
|
66
|
+
|
|
67
|
+
# repr gives the shortest digits that round-trip, the same digits as JavaScript.
|
|
68
|
+
sign, digits, exponent = Decimal(repr(number)).as_tuple()
|
|
69
|
+
text = "".join(str(d) for d in digits).rstrip("0") or "0"
|
|
70
|
+
# Position of the decimal point relative to the start of the digit string.
|
|
71
|
+
point = len(digits) + exponent
|
|
72
|
+
k = len(text)
|
|
73
|
+
n = point
|
|
74
|
+
|
|
75
|
+
if k <= n <= 21:
|
|
76
|
+
return text + "0" * (n - k)
|
|
77
|
+
if 0 < n <= 21:
|
|
78
|
+
return text[:n] + "." + text[n:]
|
|
79
|
+
if -6 < n <= 0:
|
|
80
|
+
return "0." + "0" * (-n) + text
|
|
81
|
+
|
|
82
|
+
e = n - 1
|
|
83
|
+
exponent_text = ("+" if e >= 0 else "-") + str(abs(e))
|
|
84
|
+
if k == 1:
|
|
85
|
+
return text + "e" + exponent_text
|
|
86
|
+
return text[0] + "." + text[1:] + "e" + exponent_text
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Command-line interface, matching bin/parmana-verify.mjs.
|
|
2
|
+
|
|
3
|
+
parmana-verify <proof.json> <trust.json>
|
|
4
|
+
parmana-verify key-fingerprint <public-key.pem>
|
|
5
|
+
|
|
6
|
+
verify exits 0 when the record is valid, 1 when it is invalid, and 2 for a
|
|
7
|
+
usage or read error. key-fingerprint prints the "sha256:..." value to pin in a
|
|
8
|
+
trust file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .trust import key_fingerprint, parse_trust_policy
|
|
15
|
+
from .verify import result_to_json, verify_execution_trust_record
|
|
16
|
+
|
|
17
|
+
USAGE = (
|
|
18
|
+
"usage: parmana-verify <proof.json> <trust.json>\n"
|
|
19
|
+
" parmana-verify key-fingerprint <public-key.pem>"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _reject_non_standard_constant(value):
|
|
24
|
+
# JSON.parse rejects NaN and Infinity, so the Python reader does too.
|
|
25
|
+
raise ValueError(f"invalid JSON constant {value}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read_json(path):
|
|
29
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
30
|
+
return json.loads(handle.read(), parse_constant=_reject_non_standard_constant)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(argv=None):
|
|
34
|
+
args = sys.argv[1:] if argv is None else argv
|
|
35
|
+
|
|
36
|
+
if args and args[0] == "key-fingerprint":
|
|
37
|
+
if len(args) != 2:
|
|
38
|
+
print(USAGE, file=sys.stderr)
|
|
39
|
+
return 2
|
|
40
|
+
try:
|
|
41
|
+
with open(args[1], "r", encoding="utf-8") as handle:
|
|
42
|
+
print(key_fingerprint(handle.read()))
|
|
43
|
+
return 0
|
|
44
|
+
except (OSError, ValueError, TypeError) as error:
|
|
45
|
+
print(f"key error: {error}", file=sys.stderr)
|
|
46
|
+
return 2
|
|
47
|
+
|
|
48
|
+
if len(args) != 2 or not args[0] or not args[1]:
|
|
49
|
+
print(USAGE, file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
|
|
52
|
+
proof_path, trust_path = args
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
proof = _read_json(proof_path)
|
|
56
|
+
policy = parse_trust_policy(_read_json(trust_path))
|
|
57
|
+
except (OSError, ValueError) as error:
|
|
58
|
+
print(f"read error: {error}", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
result = verify_execution_trust_record(proof, policy.keys, policy.hybrid_required_from)
|
|
62
|
+
print(result_to_json(result))
|
|
63
|
+
|
|
64
|
+
return 0 if result["valid"] else 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
sys.exit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""ISO-8601 timestamp parsing for the verifier.
|
|
2
|
+
|
|
3
|
+
Accepts the forms the Parmana records and trust files use: a date, or a date
|
|
4
|
+
and time with an explicit offset ("Z" or "+hh:mm"). A timestamp without an
|
|
5
|
+
offset is rejected, rather than read in the local time zone as JavaScript's
|
|
6
|
+
Date.parse would, so that a result never depends on the machine's clock setting.
|
|
7
|
+
Returns None when the value is not such a timestamp.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
|
|
13
|
+
_DATE_TIME = re.compile(
|
|
14
|
+
r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:\d{2})$"
|
|
15
|
+
)
|
|
16
|
+
_DATE_ONLY = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_timestamp(value):
|
|
20
|
+
if not isinstance(value, str):
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
date_only = _DATE_ONLY.match(value)
|
|
24
|
+
if date_only:
|
|
25
|
+
year, month, day = (int(part) for part in date_only.groups())
|
|
26
|
+
try:
|
|
27
|
+
return datetime(year, month, day, tzinfo=timezone.utc)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
match = _DATE_TIME.match(value)
|
|
32
|
+
if not match:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
year, month, day, hour, minute = (int(part) for part in match.groups()[:5])
|
|
36
|
+
second = int(match.group(6) or 0)
|
|
37
|
+
# Keep microsecond precision: pad or truncate the fraction to six digits.
|
|
38
|
+
microsecond = int(((match.group(7) or "") + "000000")[:6])
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
moment = datetime(year, month, day, hour, minute, second, microsecond, tzinfo=timezone.utc)
|
|
42
|
+
return moment - _offset(match.group(8))
|
|
43
|
+
except (ValueError, OverflowError):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _offset(text):
|
|
48
|
+
if text == "Z":
|
|
49
|
+
return timedelta(0)
|
|
50
|
+
sign = 1 if text[0] == "+" else -1
|
|
51
|
+
hours, minutes = int(text[1:3]), int(text[4:6])
|
|
52
|
+
if hours > 23 or minutes > 59:
|
|
53
|
+
raise ValueError("offset out of range")
|
|
54
|
+
return sign * timedelta(hours=hours, minutes=minutes)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Trust set: the keys a verifier accepts, each pinned by fingerprint.
|
|
2
|
+
|
|
3
|
+
Mirrors src/trust.mjs. A key is used only when all of these hold:
|
|
4
|
+
- its PEM hashes to the pinned fingerprint (SHA-256 of the SPKI DER);
|
|
5
|
+
- its algorithm matches the algorithm the signature names;
|
|
6
|
+
- it is not marked compromised;
|
|
7
|
+
- the record's createdAt falls within [validFrom, validUntil).
|
|
8
|
+
|
|
9
|
+
The trust set is supplied by the caller. The verifier never fetches keys.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from cryptography.hazmat.primitives import serialization
|
|
18
|
+
|
|
19
|
+
from .timestamps import parse_timestamp
|
|
20
|
+
|
|
21
|
+
ALGORITHM_NAMES = ["ed25519", "dilithium3"]
|
|
22
|
+
_FINGERPRINT = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TrustEntry:
|
|
27
|
+
key_id: str
|
|
28
|
+
algorithm: str
|
|
29
|
+
public_key_pem: str
|
|
30
|
+
fingerprint: str
|
|
31
|
+
valid_from: str
|
|
32
|
+
valid_until: Optional[str]
|
|
33
|
+
compromised: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class TrustPolicy:
|
|
38
|
+
keys: dict = field(default_factory=dict)
|
|
39
|
+
hybrid_required_from: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def key_fingerprint(public_key_pem):
|
|
43
|
+
"""Return the "sha256:..." value to pin for a PEM public key."""
|
|
44
|
+
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
|
|
45
|
+
return fingerprint_of_key(public_key)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fingerprint_of_key(public_key):
|
|
49
|
+
der = public_key.public_bytes(
|
|
50
|
+
serialization.Encoding.DER,
|
|
51
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
52
|
+
)
|
|
53
|
+
return "sha256:" + hashlib.sha256(der).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_trust_policy(document):
|
|
57
|
+
"""Parse a trust-set document into a TrustPolicy. Raises ValueError when malformed."""
|
|
58
|
+
if not isinstance(document, dict) or not isinstance(document.get("keys"), list):
|
|
59
|
+
raise ValueError('trust set must be an object with a "keys" array')
|
|
60
|
+
|
|
61
|
+
hybrid_required_from = None
|
|
62
|
+
cutover = document.get("hybridRequiredFrom")
|
|
63
|
+
if cutover is not None:
|
|
64
|
+
if not isinstance(cutover, str) or not cutover.endswith("Z") or parse_timestamp(cutover) is None:
|
|
65
|
+
raise ValueError('hybridRequiredFrom must be an ISO-8601 UTC timestamp ending in "Z"')
|
|
66
|
+
hybrid_required_from = cutover
|
|
67
|
+
|
|
68
|
+
keys = {}
|
|
69
|
+
|
|
70
|
+
for index, entry in enumerate(document["keys"]):
|
|
71
|
+
at = f"keys[{index}]"
|
|
72
|
+
|
|
73
|
+
if not isinstance(entry, dict):
|
|
74
|
+
raise ValueError(f"{at} must be an object")
|
|
75
|
+
|
|
76
|
+
key_id = entry.get("keyId")
|
|
77
|
+
if not isinstance(key_id, str) or key_id == "":
|
|
78
|
+
raise ValueError(f"{at}.keyId must be a non-empty string")
|
|
79
|
+
|
|
80
|
+
if key_id in keys:
|
|
81
|
+
raise ValueError(f'duplicate keyId "{key_id}"')
|
|
82
|
+
|
|
83
|
+
if entry.get("algorithm") not in ALGORITHM_NAMES:
|
|
84
|
+
raise ValueError(f"{at}.algorithm must be one of {', '.join(ALGORITHM_NAMES)}")
|
|
85
|
+
|
|
86
|
+
if not isinstance(entry.get("publicKeyPem"), str):
|
|
87
|
+
raise ValueError(f"{at}.publicKeyPem must be a PEM string")
|
|
88
|
+
|
|
89
|
+
fingerprint = entry.get("fingerprint")
|
|
90
|
+
if not isinstance(fingerprint, str) or not _FINGERPRINT.match(fingerprint):
|
|
91
|
+
raise ValueError(f'{at}.fingerprint must be "sha256:" followed by 64 lowercase hex digits')
|
|
92
|
+
|
|
93
|
+
if parse_timestamp(entry.get("validFrom")) is None:
|
|
94
|
+
raise ValueError(f"{at}.validFrom must be an ISO timestamp")
|
|
95
|
+
|
|
96
|
+
valid_until = entry.get("validUntil")
|
|
97
|
+
if valid_until is not None and parse_timestamp(valid_until) is None:
|
|
98
|
+
raise ValueError(f"{at}.validUntil must be an ISO timestamp or null")
|
|
99
|
+
|
|
100
|
+
compromised = entry.get("compromised", False)
|
|
101
|
+
if compromised is None:
|
|
102
|
+
compromised = False
|
|
103
|
+
if not isinstance(compromised, bool):
|
|
104
|
+
raise ValueError(f"{at}.compromised must be true or false")
|
|
105
|
+
|
|
106
|
+
keys[key_id] = TrustEntry(
|
|
107
|
+
key_id=key_id,
|
|
108
|
+
algorithm=entry["algorithm"],
|
|
109
|
+
public_key_pem=entry["publicKeyPem"],
|
|
110
|
+
fingerprint=fingerprint,
|
|
111
|
+
valid_from=entry["validFrom"],
|
|
112
|
+
valid_until=valid_until,
|
|
113
|
+
compromised=compromised,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return TrustPolicy(keys=keys, hybrid_required_from=hybrid_required_from)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Offline verification of an Execution Trust Record.
|
|
2
|
+
|
|
3
|
+
Mirrors src/verify.mjs. Checks run on the record alone:
|
|
4
|
+
1. trustRecordHash equals the SHA-256 (hex) of the canonical record fields.
|
|
5
|
+
2. signature is a valid signature over those canonical bytes, made by a
|
|
6
|
+
trusted key named by signature.keyId (see trust.py for the trust rules).
|
|
7
|
+
3. If signatures is present, it is a hybrid envelope: at least two entries,
|
|
8
|
+
no repeated algorithm, and every entry verifies over the message for its
|
|
9
|
+
schemaVersion (default 2). A record with schemaVersion 3 must carry one.
|
|
10
|
+
4. If a cutover is given (hybrid_required_from, from the trust file), a record
|
|
11
|
+
created at or after it must pass check 3.
|
|
12
|
+
|
|
13
|
+
Schema versions:
|
|
14
|
+
2 the message is the canonical fields plus schemaVersion.
|
|
15
|
+
3 the message is every top-level field except signature, signatures,
|
|
16
|
+
schemaVersion, verifications and receipts, plus schemaVersion.
|
|
17
|
+
|
|
18
|
+
Supported algorithms:
|
|
19
|
+
ed25519 Ed25519 keys. Messages over 4096 bytes may be signed as a
|
|
20
|
+
commitment: the prefix below followed by the SHA-512 digest of
|
|
21
|
+
the canonical bytes. A raw signature is also accepted. A
|
|
22
|
+
commitment over a small message is rejected.
|
|
23
|
+
dilithium3 ML-DSA-65 keys. This verifier does not check them, because the
|
|
24
|
+
cryptography package does not provide ML-DSA. Such an entry is
|
|
25
|
+
reported as a failed check, so a hybrid record is never accepted
|
|
26
|
+
on its Ed25519 part alone.
|
|
27
|
+
|
|
28
|
+
The verifier reads no environment variables and private keys, and makes no
|
|
29
|
+
network calls.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import binascii
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
|
|
38
|
+
from cryptography.exceptions import InvalidSignature
|
|
39
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
40
|
+
from cryptography.hazmat.primitives import serialization
|
|
41
|
+
|
|
42
|
+
from .canonical import canonical_bytes
|
|
43
|
+
from .timestamps import parse_timestamp
|
|
44
|
+
from .trust import fingerprint_of_key
|
|
45
|
+
|
|
46
|
+
RAW_MESSAGE_LIMIT_BYTES = 4096
|
|
47
|
+
COMMITMENT_PREFIX = b"PARMANA-ED25519-LARGE-MESSAGE-V1\x00"
|
|
48
|
+
DEFAULT_SCHEMA_VERSION = 2
|
|
49
|
+
WHOLE_RECORD_SCHEMA_VERSION = 3
|
|
50
|
+
SUPPORTED_SCHEMA_VERSIONS = (2, 3)
|
|
51
|
+
|
|
52
|
+
LEGACY_FIELDS = ("trustRecordId", "businessTransactionId", "transaction", "authorization", "overrides", "executions", "createdAt")
|
|
53
|
+
WHOLE_RECORD_EXCLUDED_FIELDS = ("signature", "signatures", "schemaVersion", "verifications", "receipts")
|
|
54
|
+
|
|
55
|
+
ALGORITHMS = {
|
|
56
|
+
"ed25519": {"commitment": True},
|
|
57
|
+
"dilithium3": {"commitment": False},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class _Context:
|
|
63
|
+
trust_keys: dict
|
|
64
|
+
created_at: object
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def canonical_trust_record(record):
|
|
68
|
+
"""The fields covered by trustRecordHash and the legacy signature."""
|
|
69
|
+
# A field the record does not have is left out, as JSON.stringify leaves out
|
|
70
|
+
# undefined values in the JavaScript verifier.
|
|
71
|
+
return {key: record[key] for key in LEGACY_FIELDS if key in record}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def hybrid_message(record, schema_version):
|
|
75
|
+
"""The message a hybrid envelope of the given schema version signs."""
|
|
76
|
+
if schema_version == WHOLE_RECORD_SCHEMA_VERSION:
|
|
77
|
+
covered = {key: value for key, value in record.items() if key not in WHOLE_RECORD_EXCLUDED_FIELDS}
|
|
78
|
+
covered["schemaVersion"] = schema_version
|
|
79
|
+
return canonical_bytes(covered)
|
|
80
|
+
|
|
81
|
+
return canonical_bytes({**canonical_trust_record(record), "schemaVersion": schema_version})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def verify_execution_trust_record(record, trust_keys, hybrid_required_from=None):
|
|
85
|
+
"""Verify a record against a trust set (a dict keyId -> TrustEntry).
|
|
86
|
+
|
|
87
|
+
Returns a dict with valid, coverage ("full" or "legacy"), errors and checks,
|
|
88
|
+
the same shape as the Node verifier's result.
|
|
89
|
+
"""
|
|
90
|
+
errors = []
|
|
91
|
+
|
|
92
|
+
if not isinstance(record, dict):
|
|
93
|
+
return {"valid": False, "coverage": "legacy", "errors": ["record must be a JSON object"], "checks": {}}
|
|
94
|
+
|
|
95
|
+
legacy_message = canonical_bytes(canonical_trust_record(record))
|
|
96
|
+
expected_hash = hashlib.sha256(legacy_message).hexdigest()
|
|
97
|
+
hash_valid = expected_hash == record.get("trustRecordHash")
|
|
98
|
+
|
|
99
|
+
if not hash_valid:
|
|
100
|
+
errors.append(f"trustRecordHash mismatch: expected {expected_hash}.")
|
|
101
|
+
|
|
102
|
+
context = _Context(trust_keys=trust_keys, created_at=record.get("createdAt"))
|
|
103
|
+
|
|
104
|
+
signature_valid = _verify_legacy_signature(record.get("signature"), legacy_message, context, errors)
|
|
105
|
+
|
|
106
|
+
checks = {"trustRecordHash": hash_valid, "signature": signature_valid}
|
|
107
|
+
|
|
108
|
+
schema_version = record.get("schemaVersion")
|
|
109
|
+
if schema_version is None:
|
|
110
|
+
schema_version = DEFAULT_SCHEMA_VERSION
|
|
111
|
+
has_envelope = record.get("signatures") is not None
|
|
112
|
+
|
|
113
|
+
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
|
114
|
+
errors.append(f"unsupported schemaVersion {schema_version}: expected 2 or 3.")
|
|
115
|
+
checks["hybridSignatures"] = False
|
|
116
|
+
elif has_envelope:
|
|
117
|
+
checks["hybridSignatures"] = _verify_hybrid_signatures(
|
|
118
|
+
record["signatures"], hybrid_message(record, schema_version), context, errors
|
|
119
|
+
)
|
|
120
|
+
elif schema_version == WHOLE_RECORD_SCHEMA_VERSION:
|
|
121
|
+
errors.append("schemaVersion 3 record has no signatures array: a whole-record signature is required.")
|
|
122
|
+
checks["hybridSignatures"] = False
|
|
123
|
+
|
|
124
|
+
cutover_ok = not _is_at_or_after_cutover(record.get("createdAt"), hybrid_required_from) or checks.get("hybridSignatures") is True
|
|
125
|
+
|
|
126
|
+
if not cutover_ok:
|
|
127
|
+
errors.append(f"record created at or after hybridRequiredFrom ({hybrid_required_from}) has no valid hybrid envelope.")
|
|
128
|
+
|
|
129
|
+
coverage = "full" if schema_version == WHOLE_RECORD_SCHEMA_VERSION and checks.get("hybridSignatures") is True else "legacy"
|
|
130
|
+
|
|
131
|
+
valid = hash_valid and signature_valid and checks.get("hybridSignatures", True) and cutover_ok
|
|
132
|
+
|
|
133
|
+
return {"valid": bool(valid), "coverage": coverage, "errors": errors, "checks": checks}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_at_or_after_cutover(created_at, hybrid_required_from):
|
|
137
|
+
if hybrid_required_from is None:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
created = parse_timestamp(created_at)
|
|
141
|
+
cutover = parse_timestamp(hybrid_required_from)
|
|
142
|
+
|
|
143
|
+
return created is not None and cutover is not None and created >= cutover
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _verify_legacy_signature(signature, message, context, errors):
|
|
147
|
+
if not _is_signature_entry(signature):
|
|
148
|
+
errors.append("signature is missing or malformed.")
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
return _verify_entry(signature, message, context, errors)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _verify_hybrid_signatures(entries, message, context, errors):
|
|
155
|
+
if not isinstance(entries, list) or not all(_is_signature_entry(entry) for entry in entries):
|
|
156
|
+
errors.append("malformed signatures: expected an array of entries with string algorithm, keyId and signature.")
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
if len(entries) < 2:
|
|
160
|
+
noun = "entry" if len(entries) == 1 else "entries"
|
|
161
|
+
errors.append(f"signatures array has {len(entries)} {noun}, need at least 2 for a hybrid record.")
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
seen = set()
|
|
165
|
+
all_valid = True
|
|
166
|
+
|
|
167
|
+
for entry in entries:
|
|
168
|
+
if entry["algorithm"] in seen:
|
|
169
|
+
errors.append(f"duplicate algorithm in signatures array: {entry['algorithm']}.")
|
|
170
|
+
all_valid = False
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
seen.add(entry["algorithm"])
|
|
174
|
+
all_valid = _verify_entry(entry, message, context, errors) and all_valid
|
|
175
|
+
|
|
176
|
+
return all_valid
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _verify_entry(entry, message, context, errors):
|
|
180
|
+
# The legacy signature field uses "value" for the same three parts.
|
|
181
|
+
signature_text = entry.get("signature") if isinstance(entry.get("signature"), str) else entry.get("value")
|
|
182
|
+
algorithm_name = entry["algorithm"]
|
|
183
|
+
key_id = entry["keyId"]
|
|
184
|
+
|
|
185
|
+
if algorithm_name not in ALGORITHMS:
|
|
186
|
+
errors.append(f"unsupported algorithm: {algorithm_name}.")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
trusted = context.trust_keys.get(key_id)
|
|
190
|
+
|
|
191
|
+
if trusted is None:
|
|
192
|
+
errors.append(f'no trusted key for keyId "{key_id}".')
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
if trusted.algorithm != algorithm_name:
|
|
196
|
+
errors.append(f'keyId "{key_id}" is trusted for {trusted.algorithm}, not {algorithm_name}.')
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
if trusted.compromised:
|
|
200
|
+
errors.append(f'keyId "{key_id}" is marked compromised; its signatures are not accepted.')
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
if not _within_validity_window(trusted, context.created_at):
|
|
204
|
+
errors.append(f'createdAt {context.created_at} is outside the validity window of keyId "{key_id}".')
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
if algorithm_name == "dilithium3":
|
|
208
|
+
errors.append(
|
|
209
|
+
"algorithm dilithium3 (ML-DSA-65) is not supported by the Python verifier; "
|
|
210
|
+
"use the Node verifier (@parmana/verifier) on Node 24+ with OpenSSL 3.5+."
|
|
211
|
+
)
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
public_key = serialization.load_pem_public_key(trusted.public_key_pem.encode("utf-8"))
|
|
216
|
+
except (ValueError, TypeError):
|
|
217
|
+
errors.append(f'public key for keyId "{key_id}" is not a valid PEM key.')
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
if fingerprint_of_key(public_key) != trusted.fingerprint:
|
|
221
|
+
errors.append(f'public key for keyId "{key_id}" does not match its pinned fingerprint.')
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
if not isinstance(public_key, Ed25519PublicKey):
|
|
225
|
+
errors.append(f'public key for keyId "{key_id}" has type {type(public_key).__name__}, expected ed25519.')
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
signature = base64.b64decode(signature_text or "", validate=False)
|
|
230
|
+
except (binascii.Error, ValueError):
|
|
231
|
+
signature = b""
|
|
232
|
+
|
|
233
|
+
raw_valid = _safe_verify(message, signature, public_key)
|
|
234
|
+
commitment_valid = (
|
|
235
|
+
not raw_valid
|
|
236
|
+
and ALGORITHMS[algorithm_name]["commitment"]
|
|
237
|
+
and len(message) > RAW_MESSAGE_LIMIT_BYTES
|
|
238
|
+
and _safe_verify(_commitment(message), signature, public_key)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if not raw_valid and not commitment_valid:
|
|
242
|
+
errors.append(f'signature verification failed for keyId "{key_id}" ({algorithm_name}).')
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _within_validity_window(trusted, created_at):
|
|
249
|
+
at = parse_timestamp(created_at)
|
|
250
|
+
|
|
251
|
+
if at is None:
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
if at < parse_timestamp(trusted.valid_from):
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
return trusted.valid_until is None or at < parse_timestamp(trusted.valid_until)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _commitment(message):
|
|
261
|
+
return COMMITMENT_PREFIX + hashlib.sha512(message).digest()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _safe_verify(message, signature, public_key):
|
|
265
|
+
try:
|
|
266
|
+
public_key.verify(signature, message)
|
|
267
|
+
return True
|
|
268
|
+
except (InvalidSignature, ValueError, TypeError):
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _is_signature_entry(value):
|
|
273
|
+
return (
|
|
274
|
+
isinstance(value, dict)
|
|
275
|
+
and isinstance(value.get("algorithm"), str)
|
|
276
|
+
and isinstance(value.get("keyId"), str)
|
|
277
|
+
and (isinstance(value.get("signature"), str) or isinstance(value.get("value"), str))
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def result_to_json(result):
|
|
282
|
+
"""Serialize a result the way the Node CLI prints it (no spaces, UTF-8)."""
|
|
283
|
+
return json.dumps(result, separators=(",", ":"), ensure_ascii=False)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parmana-verifier
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline verifier for Parmana Execution Trust Records (Python).
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Repository, https://github.com/pavancharak/parmana-verifier
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: cryptography>=42
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
12
|
+
|
|
13
|
+
# parmana-verifier (Python)
|
|
14
|
+
|
|
15
|
+
Check whether a Parmana Execution Trust Record was signed by a key you trust, using only the record and a trust file. No network access, no account, no Parmana server needed.
|
|
16
|
+
|
|
17
|
+
This is the Python counterpart of the Node verifier in the repository root. It checks the same things in the same order, and it returns the same result shape and exit codes. Its results match the Node verifier on every test vector and on records generated by each implementation.
|
|
18
|
+
|
|
19
|
+
## What it checks, and what it does not
|
|
20
|
+
|
|
21
|
+
**Checked**
|
|
22
|
+
|
|
23
|
+
- The record hash (`trustRecordHash`) against the canonical record fields.
|
|
24
|
+
- The Ed25519 signature, over the legacy message, from a key in your trust file.
|
|
25
|
+
- Ed25519 signatures in a hybrid `signatures` array, and the whole-record (schema version 3) message.
|
|
26
|
+
- Trust rules: the key's fingerprint, its validity window, and the `compromised` flag.
|
|
27
|
+
- The `hybridRequiredFrom` cutover, when the trust file sets one.
|
|
28
|
+
|
|
29
|
+
**Not checked: ML-DSA-65 (`dilithium3`) signatures.** The `cryptography` package does not provide ML-DSA. A record with a `dilithium3` entry is reported as invalid, so this verifier never accepts a hybrid record on its Ed25519 part alone. To check the ML-DSA-65 part, use the Node verifier (`@parmana/verifier`) on Node 24 or later with OpenSSL 3.5 or later.
|
|
30
|
+
|
|
31
|
+
**Other differences from the Node verifier**
|
|
32
|
+
|
|
33
|
+
- A timestamp without a time zone offset is rejected. Node reads it in the local time zone, so this verifier gives the same answer on every machine.
|
|
34
|
+
- Integers beyond 2^53 are rounded the way JavaScript rounds them when it parses JSON. Use records written by Parmana, which never contain such integers.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Requires Python 3.11 or later (tested on 3.11, 3.12 and 3.13). The only dependency is `cryptography` (version 42 or later). It is not yet published to PyPI, so install from a clone of this repository:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
cd python
|
|
42
|
+
pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For the tests, install the `test` extra as well: `pip install -e ".[test]"`.
|
|
46
|
+
|
|
47
|
+
## Command line
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
parmana-verify <proof.json> <trust.json>
|
|
51
|
+
parmana-verify key-fingerprint <public-key.pem>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `verify` prints one line of JSON and exits `0` when the record is valid, `1` when it is invalid, and `2` for a usage or read error.
|
|
55
|
+
- `key-fingerprint` prints the `sha256:...` value to pin in a trust file.
|
|
56
|
+
|
|
57
|
+
The output has the same fields as the Node CLI: `valid`, `coverage`, `errors` and `checks`.
|
|
58
|
+
|
|
59
|
+
## Python API
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import json
|
|
63
|
+
from parmana_verifier import parse_trust_policy, verify_execution_trust_record
|
|
64
|
+
|
|
65
|
+
record = json.load(open("proof.json"))
|
|
66
|
+
policy = parse_trust_policy(json.load(open("trust.json")))
|
|
67
|
+
|
|
68
|
+
result = verify_execution_trust_record(record, policy.keys, policy.hybrid_required_from)
|
|
69
|
+
print(result["valid"], result["errors"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Trust file
|
|
73
|
+
|
|
74
|
+
The trust file lists each key you accept, pinned by its fingerprint. See the [main README](../README.md#installation-and-usage) for the format. The verifier never fetches keys.
|
|
75
|
+
|
|
76
|
+
## Tests
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
cd python
|
|
80
|
+
pip install -e ".[test]"
|
|
81
|
+
pytest
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The tests run the shared test vectors in `../test/vectors`, check the canonical serialization against JavaScript's number and string formatting, and generate Ed25519 records at test time to cover the trust rules and the CLI.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
parmana_verifier/__init__.py
|
|
4
|
+
parmana_verifier/__main__.py
|
|
5
|
+
parmana_verifier/canonical.py
|
|
6
|
+
parmana_verifier/cli.py
|
|
7
|
+
parmana_verifier/timestamps.py
|
|
8
|
+
parmana_verifier/trust.py
|
|
9
|
+
parmana_verifier/verify.py
|
|
10
|
+
parmana_verifier.egg-info/PKG-INFO
|
|
11
|
+
parmana_verifier.egg-info/SOURCES.txt
|
|
12
|
+
parmana_verifier.egg-info/dependency_links.txt
|
|
13
|
+
parmana_verifier.egg-info/entry_points.txt
|
|
14
|
+
parmana_verifier.egg-info/requires.txt
|
|
15
|
+
parmana_verifier.egg-info/top_level.txt
|
|
16
|
+
tests/test_verifier.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
parmana_verifier
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "parmana-verifier"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Offline verifier for Parmana Execution Trust Records (Python)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = ["cryptography>=42"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
test = ["pytest>=8"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
parmana-verify = "parmana_verifier.cli:main"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Repository = "https://github.com/pavancharak/parmana-verifier"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = ["parmana_verifier*"]
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from cryptography.hazmat.primitives import serialization
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
12
|
+
|
|
13
|
+
from parmana_verifier.canonical import canonical_bytes, js_number
|
|
14
|
+
from parmana_verifier.timestamps import parse_timestamp
|
|
15
|
+
from parmana_verifier.trust import parse_trust_policy, key_fingerprint
|
|
16
|
+
from parmana_verifier.verify import canonical_trust_record, verify_execution_trust_record, hybrid_message
|
|
17
|
+
|
|
18
|
+
VECTORS = Path(__file__).resolve().parents[2] / "test" / "vectors"
|
|
19
|
+
PREFIX = b"PARMANA-ED25519-LARGE-MESSAGE-V1\x00"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load(name):
|
|
23
|
+
return json.loads((VECTORS / name).read_text(encoding="utf-8"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def trust_entry(key_id, private_key, **overrides):
|
|
27
|
+
public = private_key.public_key()
|
|
28
|
+
der = public.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
29
|
+
pem = public.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode()
|
|
30
|
+
entry = {
|
|
31
|
+
"keyId": key_id,
|
|
32
|
+
"algorithm": "ed25519",
|
|
33
|
+
"publicKeyPem": pem,
|
|
34
|
+
"fingerprint": "sha256:" + hashlib.sha256(der).hexdigest(),
|
|
35
|
+
"validFrom": "2026-01-01T00:00:00.000Z",
|
|
36
|
+
"validUntil": None,
|
|
37
|
+
"compromised": False,
|
|
38
|
+
}
|
|
39
|
+
entry.update(overrides)
|
|
40
|
+
return entry
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def signed_record(private_key, key_id, body, created="2026-06-01T12:00:00.000Z", mode="raw"):
|
|
44
|
+
record = {
|
|
45
|
+
"trustRecordId": "tr-1",
|
|
46
|
+
"businessTransactionId": "btx-1",
|
|
47
|
+
"transaction": body,
|
|
48
|
+
"authorization": {"approvedBy": "ana"},
|
|
49
|
+
"overrides": None,
|
|
50
|
+
"executions": [{"n": 1}],
|
|
51
|
+
"createdAt": created,
|
|
52
|
+
}
|
|
53
|
+
message = canonical_bytes(canonical_trust_record(record))
|
|
54
|
+
record["trustRecordHash"] = hashlib.sha256(message).hexdigest()
|
|
55
|
+
signed = message if mode == "raw" else PREFIX + hashlib.sha512(message).digest()
|
|
56
|
+
record["signature"] = {
|
|
57
|
+
"algorithm": "ed25519",
|
|
58
|
+
"keyId": key_id,
|
|
59
|
+
"signature": base64.b64encode(private_key.sign(signed)).decode(),
|
|
60
|
+
}
|
|
61
|
+
return record
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def trust_map(*entries):
|
|
65
|
+
return parse_trust_policy({"keys": list(entries)}).keys
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Vectors: the same records the Node verifier tests against.
|
|
69
|
+
|
|
70
|
+
def test_tampered_record_fails_hash_and_signature():
|
|
71
|
+
trust = parse_trust_policy(load("trust.json"))
|
|
72
|
+
result = verify_execution_trust_record(load("record-tampered-amount.json"), trust.keys)
|
|
73
|
+
assert result["valid"] is False
|
|
74
|
+
assert result["checks"]["trustRecordHash"] is False
|
|
75
|
+
assert result["checks"]["signature"] is False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_v2_record_ed25519_part_verifies_and_hybrid_is_reported_unsupported():
|
|
79
|
+
trust = parse_trust_policy(load("trust.json"))
|
|
80
|
+
result = verify_execution_trust_record(load("record-explicit-v2.json"), trust.keys)
|
|
81
|
+
assert result["checks"] == {"trustRecordHash": True, "signature": True, "hybridSignatures": False}
|
|
82
|
+
assert result["valid"] is False
|
|
83
|
+
assert any("dilithium3" in error for error in result["errors"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_whole_record_v3_ed25519_entry_verifies_over_the_v3_message():
|
|
87
|
+
# The ed25519 entry of the v3 vector must verify over the whole-record
|
|
88
|
+
# message. A failure here would mean hybrid_message() builds different bytes.
|
|
89
|
+
record = load("record-whole-record-v3.json")
|
|
90
|
+
trust = parse_trust_policy(load("trust-whole-record.json"))
|
|
91
|
+
result = verify_execution_trust_record(record, trust.keys, trust.hybrid_required_from)
|
|
92
|
+
assert not any("signature verification failed" in error for error in result["errors"])
|
|
93
|
+
ed_entry = next(entry for entry in record["signatures"] if entry["algorithm"] == "ed25519")
|
|
94
|
+
assert ed_entry["keyId"] in trust.keys
|
|
95
|
+
assert hybrid_message(record, 3)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Canonical serialization, matching ECMAScript Number and JSON.stringify.
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize(
|
|
101
|
+
"number, expected",
|
|
102
|
+
[
|
|
103
|
+
(1.0, "1"),
|
|
104
|
+
(-0.0, "0"),
|
|
105
|
+
(100.0, "100"),
|
|
106
|
+
(1e20, "100000000000000000000"),
|
|
107
|
+
(1e21, "1e+21"),
|
|
108
|
+
(1e-7, "1e-7"),
|
|
109
|
+
(0.00001, "0.00001"),
|
|
110
|
+
(0.1 + 0.2, "0.30000000000000004"),
|
|
111
|
+
(123.456, "123.456"),
|
|
112
|
+
(5e-324, "5e-324"),
|
|
113
|
+
(1.7976931348623157e308, "1.7976931348623157e+308"),
|
|
114
|
+
],
|
|
115
|
+
)
|
|
116
|
+
def test_js_number_format(number, expected):
|
|
117
|
+
assert js_number(number) == expected
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_keys_sort_in_utf16_code_unit_order():
|
|
121
|
+
# U+1F600 is a surrogate pair in UTF-16, so it sorts before U+FFFD.
|
|
122
|
+
value = {"": 1, "\U0001F600": 2, "a": 3, "B": 4}
|
|
123
|
+
assert canonical_bytes(value).decode("utf-8") == '{"B":4,"a":3,"\U0001F600":2,"":1}'
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_strings_escape_like_json_stringify():
|
|
127
|
+
value = {"s": "tab\tquote\"ctrl\u0001"}
|
|
128
|
+
assert canonical_bytes(value).decode("utf-8") == '{"s":"tab\\tquote\\"ctrl\\u0001"}'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_lone_surrogate_is_escaped_not_emitted_raw():
|
|
132
|
+
value = json.loads('{"s": "\\ud800"}')
|
|
133
|
+
assert canonical_bytes(value) == b'{"s":"\\ud800"}'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_integer_beyond_safe_range_is_rounded_as_javascript_does():
|
|
137
|
+
value = json.loads("123456789012345678901234")
|
|
138
|
+
assert canonical_bytes(value) == b"1.2345678901234569e+23"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Records generated at test time, signed with Ed25519 keys made here.
|
|
142
|
+
|
|
143
|
+
@pytest.fixture
|
|
144
|
+
def key():
|
|
145
|
+
return Ed25519PrivateKey.generate()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_raw_signature_verifies(key):
|
|
149
|
+
trust = trust_map(trust_entry("main", key))
|
|
150
|
+
record = signed_record(key, "main", {"a": 1})
|
|
151
|
+
result = verify_execution_trust_record(record, trust)
|
|
152
|
+
assert result == {"valid": True, "coverage": "legacy", "errors": [], "checks": {"trustRecordHash": True, "signature": True}}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_large_message_accepts_commitment_signature(key):
|
|
156
|
+
trust = trust_map(trust_entry("main", key))
|
|
157
|
+
body = {"items": ["x" * 200] * 40}
|
|
158
|
+
record = signed_record(key, "main", body, mode="commitment")
|
|
159
|
+
assert verify_execution_trust_record(record, trust)["valid"] is True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_small_message_rejects_commitment_signature(key):
|
|
163
|
+
trust = trust_map(trust_entry("main", key))
|
|
164
|
+
record = signed_record(key, "main", {"a": 1}, mode="commitment")
|
|
165
|
+
result = verify_execution_trust_record(record, trust)
|
|
166
|
+
assert result["valid"] is False
|
|
167
|
+
assert result["checks"]["signature"] is False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_tampered_signature_is_rejected(key):
|
|
171
|
+
trust = trust_map(trust_entry("main", key))
|
|
172
|
+
record = signed_record(key, "main", {"a": 1})
|
|
173
|
+
raw = bytearray(base64.b64decode(record["signature"]["signature"]))
|
|
174
|
+
raw[0] ^= 1
|
|
175
|
+
record["signature"]["signature"] = base64.b64encode(bytes(raw)).decode()
|
|
176
|
+
assert verify_execution_trust_record(record, trust)["valid"] is False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_unknown_keyid_is_rejected(key):
|
|
180
|
+
trust = trust_map(trust_entry("main", key))
|
|
181
|
+
record = signed_record(key, "nobody", {"a": 1})
|
|
182
|
+
assert "no trusted key" in verify_execution_trust_record(record, trust)["errors"][0]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_compromised_key_is_rejected(key):
|
|
186
|
+
trust = trust_map(trust_entry("main", key, compromised=True))
|
|
187
|
+
record = signed_record(key, "main", {"a": 1})
|
|
188
|
+
assert verify_execution_trust_record(record, trust)["valid"] is False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_record_before_validity_window_is_rejected(key):
|
|
192
|
+
trust = trust_map(trust_entry("main", key, validFrom="2026-07-01T00:00:00.000Z"))
|
|
193
|
+
record = signed_record(key, "main", {"a": 1}, created="2026-06-01T00:00:00Z")
|
|
194
|
+
result = verify_execution_trust_record(record, trust)
|
|
195
|
+
assert result["valid"] is False
|
|
196
|
+
assert "outside the validity window" in result["errors"][0]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_record_after_validity_end_is_rejected(key):
|
|
200
|
+
trust = trust_map(trust_entry("main", key, validUntil="2026-05-01T00:00:00.000Z"))
|
|
201
|
+
record = signed_record(key, "main", {"a": 1}, created="2026-06-01T00:00:00Z")
|
|
202
|
+
assert verify_execution_trust_record(record, trust)["valid"] is False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_cutover_requires_hybrid_envelope_for_later_records(key):
|
|
206
|
+
trust = trust_map(trust_entry("main", key))
|
|
207
|
+
record = signed_record(key, "main", {"a": 1}, created="2026-06-01T12:00:00Z")
|
|
208
|
+
result = verify_execution_trust_record(record, trust, "2026-01-01T00:00:00.000Z")
|
|
209
|
+
assert result["valid"] is False
|
|
210
|
+
assert "hybridRequiredFrom" in result["errors"][-1]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_cutover_does_not_apply_to_earlier_records(key):
|
|
214
|
+
trust = trust_map(trust_entry("main", key, validFrom="2024-01-01T00:00:00.000Z"))
|
|
215
|
+
record = signed_record(key, "main", {"a": 1}, created="2025-06-01T12:00:00Z")
|
|
216
|
+
assert verify_execution_trust_record(record, trust, "2026-01-01T00:00:00.000Z")["valid"] is True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_fingerprint_mismatch_is_rejected(key):
|
|
220
|
+
entry = trust_entry("main", key)
|
|
221
|
+
entry["fingerprint"] = "sha256:" + "0" * 64
|
|
222
|
+
result = verify_execution_trust_record(signed_record(key, "main", {"a": 1}), trust_map(entry))
|
|
223
|
+
assert result["valid"] is False
|
|
224
|
+
assert "pinned fingerprint" in result["errors"][0]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_non_object_record_is_rejected():
|
|
228
|
+
result = verify_execution_trust_record(["not", "an", "object"], {})
|
|
229
|
+
assert result == {"valid": False, "coverage": "legacy", "errors": ["record must be a JSON object"], "checks": {}}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_key_fingerprint_matches_trust_entry(key):
|
|
233
|
+
entry = trust_entry("main", key)
|
|
234
|
+
assert key_fingerprint(entry["publicKeyPem"]) == entry["fingerprint"]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Trust set and timestamps.
|
|
238
|
+
|
|
239
|
+
@pytest.mark.parametrize(
|
|
240
|
+
"document, message",
|
|
241
|
+
[
|
|
242
|
+
({}, 'trust set must be an object with a "keys" array'),
|
|
243
|
+
({"keys": [{"keyId": ""}]}, "keys[0].keyId must be a non-empty string"),
|
|
244
|
+
({"keys": [{"keyId": "a", "algorithm": "rsa"}]}, "keys[0].algorithm must be one of ed25519, dilithium3"),
|
|
245
|
+
({"keys": [], "hybridRequiredFrom": "2026-01-01T00:00:00"}, 'hybridRequiredFrom must be an ISO-8601 UTC timestamp ending in "Z"'),
|
|
246
|
+
],
|
|
247
|
+
)
|
|
248
|
+
def test_malformed_trust_set_is_rejected(document, message):
|
|
249
|
+
with pytest.raises(ValueError, match=re.escape(message)):
|
|
250
|
+
parse_trust_policy(document)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_timestamp_forms():
|
|
254
|
+
assert parse_timestamp("2026-01-01T00:00:00.000Z").isoformat() == "2026-01-01T00:00:00+00:00"
|
|
255
|
+
assert parse_timestamp("2026-01-01T02:00:00+02:00").isoformat() == "2026-01-01T00:00:00+00:00"
|
|
256
|
+
assert parse_timestamp("2026-01-01").isoformat() == "2026-01-01T00:00:00+00:00"
|
|
257
|
+
# Fractions beyond microseconds are truncated, as V8's Date.parse accepts them.
|
|
258
|
+
assert parse_timestamp("2026-01-01T00:00:00.1234567Z").microsecond == 123456
|
|
259
|
+
assert parse_timestamp("2026-01-01T00:00:00") is None
|
|
260
|
+
assert parse_timestamp("2026-13-01T00:00:00Z") is None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# Command line.
|
|
264
|
+
|
|
265
|
+
def run_cli(*args):
|
|
266
|
+
return subprocess.run([sys.executable, "-m", "parmana_verifier", *args], capture_output=True, text=True,
|
|
267
|
+
cwd=Path(__file__).resolve().parents[1])
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_cli_exit_codes(tmp_path, key):
|
|
271
|
+
trust_path = tmp_path / "trust.json"
|
|
272
|
+
trust_path.write_text(json.dumps({"keys": [trust_entry("main", key)]}))
|
|
273
|
+
good = tmp_path / "good.json"
|
|
274
|
+
good.write_text(json.dumps(signed_record(key, "main", {"a": 1})))
|
|
275
|
+
bad = tmp_path / "bad.json"
|
|
276
|
+
bad.write_text(json.dumps(load("record-tampered-amount.json")))
|
|
277
|
+
|
|
278
|
+
assert run_cli(str(good), str(trust_path)).returncode == 0
|
|
279
|
+
assert run_cli(str(bad), str(trust_path)).returncode == 1
|
|
280
|
+
assert run_cli(str(good)).returncode == 2
|
|
281
|
+
assert run_cli(str(tmp_path / "missing.json"), str(trust_path)).returncode == 2
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_cli_prints_one_json_line(tmp_path, key):
|
|
285
|
+
trust_path = tmp_path / "trust.json"
|
|
286
|
+
trust_path.write_text(json.dumps({"keys": [trust_entry("main", key)]}))
|
|
287
|
+
good = tmp_path / "good.json"
|
|
288
|
+
good.write_text(json.dumps(signed_record(key, "main", {"a": 1})))
|
|
289
|
+
output = run_cli(str(good), str(trust_path)).stdout
|
|
290
|
+
assert json.loads(output) == {"valid": True, "coverage": "legacy", "errors": [], "checks": {"trustRecordHash": True, "signature": True}}
|
|
291
|
+
assert output.count("\n") == 1
|