zerynex-python 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zerynex_py/__init__.py +21 -0
- zerynex_py/agent_workflows.py +125 -0
- zerynex_py/client.py +406 -0
- zerynex_py/fixtures/clean-v6.bin +0 -0
- zerynex_py/fixtures/refund-isolated.json +21 -0
- zerynex_py/fixtures/refund-regression-expanded.json +29 -0
- zerynex_py/fixtures/refund-risky.json +21 -0
- zerynex_py/fixtures/rule-catalog.json +95 -0
- zerynex_py/fixtures/transparent-v6.bin +0 -0
- zerynex_py/fixtures/zallet-pczt-lifecycle-contract.json +87 -0
- zerynex_py/fixtures/zallet-pczt-lifecycle-synthetic-openrpc.json +127 -0
- zerynex_py/mcp_doctor.py +152 -0
- zerynex_py/mcp_server.py +391 -0
- zerynex_py/node.py +189 -0
- zerynex_py/preflight.py +42 -0
- zerynex_py/project.py +313 -0
- zerynex_py/refund_privacy.py +252 -0
- zerynex_py/refund_workflows.py +118 -0
- zerynex_py/rpc_contract.py +172 -0
- zerynex_py/rules.py +55 -0
- zerynex_py/z3.py +107 -0
- zerynex_py/zallet_fixture.py +158 -0
- zerynex_python-0.2.0.dist-info/METADATA +287 -0
- zerynex_python-0.2.0.dist-info/RECORD +28 -0
- zerynex_python-0.2.0.dist-info/WHEEL +5 -0
- zerynex_python-0.2.0.dist-info/entry_points.txt +10 -0
- zerynex_python-0.2.0.dist-info/licenses/LICENSE +21 -0
- zerynex_python-0.2.0.dist-info/top_level.txt +1 -0
zerynex_py/project.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Offline, non-destructive Zcash project bootstrap and real Rust integration verification.
|
|
2
|
+
|
|
3
|
+
Only ships reviewed, nonspendable public test vectors. Never reads or copies
|
|
4
|
+
real wallet PCZTs, keys, node credentials, or unapproved workspace files.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
from importlib import resources
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from .client import Policy, ZerynexClient, ZerynexError
|
|
18
|
+
|
|
19
|
+
SCHEMA = 1
|
|
20
|
+
MAX_POLICY_BYTES = 4096
|
|
21
|
+
PREFIX = Path('.zerynex')
|
|
22
|
+
POLICY = PREFIX / 'policy.json'
|
|
23
|
+
CLEAN = PREFIX / 'fixtures' / 'clean-v6.bin'
|
|
24
|
+
TRANSPARENT = PREFIX / 'fixtures' / 'transparent-v6.bin'
|
|
25
|
+
MANIFEST = PREFIX / 'project.json'
|
|
26
|
+
WORKFLOW = Path('.github/workflows/zerynex-privacy.yml')
|
|
27
|
+
# A reviewed, immutable and publicly available release-base revision.
|
|
28
|
+
ACTION_SHA = '8dcbbba5584d1f168ec688ca42cd8f185ea904d1'
|
|
29
|
+
POLICY_BYTES = (json.dumps({'profile': 'external_signer', 'deny_transparent': True,
|
|
30
|
+
'deny_sapling': False}, indent=2) + '\n').encode()
|
|
31
|
+
WORKFLOW_BYTES = ('''# Synthetic, nonspendable PCZT only. Do not add wallet transactions or keys.
|
|
32
|
+
name: Zcash privacy regression
|
|
33
|
+
on: [push, pull_request]
|
|
34
|
+
permissions:
|
|
35
|
+
contents: read
|
|
36
|
+
jobs:
|
|
37
|
+
privacy:
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
# Review and update this pinned immutable commit deliberately.
|
|
42
|
+
- uses: Oluwa-Laughter/zerynex@''' + ACTION_SHA + '''
|
|
43
|
+
id: privacy
|
|
44
|
+
with:
|
|
45
|
+
pczt-path: .zerynex/fixtures/clean-v6.bin
|
|
46
|
+
policy-path: .zerynex/policy.json
|
|
47
|
+
fail-on-findings: 'true'
|
|
48
|
+
''').encode()
|
|
49
|
+
|
|
50
|
+
class ProjectError(Exception):
|
|
51
|
+
"""Safe operator-facing message without exposing private project paths."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _root(path: str) -> Path:
|
|
55
|
+
candidate = Path(path).expanduser().absolute()
|
|
56
|
+
if candidate.is_symlink():
|
|
57
|
+
raise ProjectError('Project root cannot be a symlink.')
|
|
58
|
+
try:
|
|
59
|
+
root = candidate.resolve(strict=True)
|
|
60
|
+
if not root.is_dir():
|
|
61
|
+
raise ProjectError('Project root must be an existing directory.')
|
|
62
|
+
except (OSError, ValueError):
|
|
63
|
+
raise ProjectError('Project root is unavailable.') from None
|
|
64
|
+
return root
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _guard(root: Path, relative: Path, *, must_exist: bool) -> Path:
|
|
68
|
+
target = root / relative
|
|
69
|
+
for ancestor in (root, *[root.joinpath(*relative.parts[:i]) for i in range(1, len(relative.parts))]):
|
|
70
|
+
if ancestor.is_symlink() or (ancestor.exists() and not ancestor.is_dir()):
|
|
71
|
+
raise ProjectError('Project contains an unsafe destination directory.')
|
|
72
|
+
if target.is_symlink() or (target.exists() and not target.is_file()):
|
|
73
|
+
raise ProjectError('Generated project file location is unsafe.')
|
|
74
|
+
if must_exist and not target.is_file():
|
|
75
|
+
raise ProjectError('Project integration has not been initialized.')
|
|
76
|
+
if not must_exist and (target.exists() or target.is_symlink()):
|
|
77
|
+
raise ProjectError('Initialization would overwrite an existing file; no files were changed.')
|
|
78
|
+
return target
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _assets(*, ci: bool) -> dict[Path, bytes]:
|
|
82
|
+
fixture_root = resources.files('zerynex_py').joinpath('fixtures')
|
|
83
|
+
clean = fixture_root.joinpath('clean-v6.bin').read_bytes()
|
|
84
|
+
flagged = fixture_root.joinpath('transparent-v6.bin').read_bytes()
|
|
85
|
+
if not (0 < len(clean) <= 8192 and 0 < len(flagged) <= 8192 and clean != flagged):
|
|
86
|
+
raise ProjectError('Bundled synthetic fixtures are unavailable.')
|
|
87
|
+
manifest = {'schema_version': SCHEMA, 'synthetic_only': True,
|
|
88
|
+
'fixture_sha256': {'clean-v6.bin': hashlib.sha256(clean).hexdigest(),
|
|
89
|
+
'transparent-v6.bin': hashlib.sha256(flagged).hexdigest()},
|
|
90
|
+
'policy_sha256': hashlib.sha256(POLICY_BYTES).hexdigest(),
|
|
91
|
+
'expected': {'clean_findings': 0, 'transparent_rule': 'PCZT-TRANSPARENT-OUTPUTS'}}
|
|
92
|
+
bundle = {POLICY: POLICY_BYTES, CLEAN: clean, TRANSPARENT: flagged,
|
|
93
|
+
MANIFEST: (json.dumps(manifest, indent=2) + '\n').encode()}
|
|
94
|
+
if ci:
|
|
95
|
+
bundle[WORKFLOW] = WORKFLOW_BYTES
|
|
96
|
+
return bundle
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def initialize(root: Path, *, ci: bool = False) -> dict[str, object]:
|
|
100
|
+
"""Preflight all paths, then write with O_EXCL. Never overwrite project data."""
|
|
101
|
+
bundle = _assets(ci=ci)
|
|
102
|
+
targets = {relative: _guard(root, relative, must_exist=False) for relative in bundle}
|
|
103
|
+
created: list[Path] = []
|
|
104
|
+
try:
|
|
105
|
+
for relative, target in targets.items():
|
|
106
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
# POSIX O_NOFOLLOW hardens the already validated target path.
|
|
108
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, 'O_NOFOLLOW', 0)
|
|
109
|
+
fd = os.open(target, flags, 0o644)
|
|
110
|
+
created.append(target)
|
|
111
|
+
with os.fdopen(fd, 'wb') as handle:
|
|
112
|
+
handle.write(bundle[relative])
|
|
113
|
+
except (OSError, ValueError):
|
|
114
|
+
for target in reversed(created):
|
|
115
|
+
try:
|
|
116
|
+
target.unlink()
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
raise ProjectError('Could not safely initialize the project. New files were rolled back.') from None
|
|
120
|
+
return {'schema_version': SCHEMA, 'initialized': True, 'synthetic_only': True,
|
|
121
|
+
'ci_configured': ci, 'created_files': [x.as_posix() for x in bundle],
|
|
122
|
+
'limitation': 'No real PCZT files, node connections, wallet access, or signing.'}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def verify(root: Path, cli: str | None) -> dict[str, object]:
|
|
126
|
+
"""Verify ONLY bundled synthetic fixture hashes using the real local Rust CLI."""
|
|
127
|
+
for file in (MANIFEST, POLICY, CLEAN, TRANSPARENT):
|
|
128
|
+
_guard(root, file, must_exist=True)
|
|
129
|
+
expected = _assets(ci=False)
|
|
130
|
+
# Do not read arbitrarily long or replaced files; bundled fixture hashes must match.
|
|
131
|
+
for relative, known in expected.items():
|
|
132
|
+
target = root / relative
|
|
133
|
+
if target.stat().st_size != len(known):
|
|
134
|
+
raise ProjectError('Project synthetic fixture or policy has been changed.')
|
|
135
|
+
with target.open('rb') as handle:
|
|
136
|
+
if handle.read(len(known) + 1) != known:
|
|
137
|
+
raise ProjectError('Project synthetic fixture or policy has been changed.')
|
|
138
|
+
client = ZerynexClient(cli=cli)
|
|
139
|
+
capabilities = client.capabilities()
|
|
140
|
+
if 'analyze_pczt' not in capabilities.operations or 'binary' not in capabilities.input_formats:
|
|
141
|
+
raise ProjectError('The Rust CLI does not support PCZT analysis.')
|
|
142
|
+
policy = Policy(deny_transparent=True)
|
|
143
|
+
clean = client.analyze_file(root / CLEAN, policy)
|
|
144
|
+
transparent = client.analyze_file(root / TRANSPARENT, policy)
|
|
145
|
+
clean_ok = clean.passed and not clean.findings
|
|
146
|
+
transparent_ok = (not transparent.passed and
|
|
147
|
+
any(f.rule == 'PCZT-TRANSPARENT-OUTPUTS' for f in transparent.findings))
|
|
148
|
+
passed = clean_ok and transparent_ok
|
|
149
|
+
return {'schema_version': SCHEMA, 'passed': passed, 'synthetic_only': True,
|
|
150
|
+
'engine': 'local_rust_cli', 'checks': {
|
|
151
|
+
'capabilities': True, 'fixture_integrity': True,
|
|
152
|
+
'clean_fixture': clean_ok, 'transparent_detection': transparent_ok,
|
|
153
|
+
}, 'limitation': 'Only known nonspendable vectors. Does not certify integration with a real wallet, node or transaction.'}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Local source policy: opt-in per PCZT. Never discover, enumerate, or upload
|
|
158
|
+
# user-controlled transaction files. The trusted developer controls this tree.
|
|
159
|
+
def _project_file(root: Path, raw: str) -> Path:
|
|
160
|
+
if not isinstance(raw, str) or not raw or "\x00" in raw:
|
|
161
|
+
raise ProjectError('Explicit PCZT path is required.')
|
|
162
|
+
candidate = Path(raw).expanduser()
|
|
163
|
+
if not candidate.is_absolute():
|
|
164
|
+
candidate = root / candidate
|
|
165
|
+
lexical = Path(os.path.abspath(candidate))
|
|
166
|
+
try:
|
|
167
|
+
relative = lexical.relative_to(root)
|
|
168
|
+
except ValueError:
|
|
169
|
+
raise ProjectError('PCZT must reside inside the selected project.') from None
|
|
170
|
+
if not relative.parts:
|
|
171
|
+
raise ProjectError('Select a PCZT file, not a directory.')
|
|
172
|
+
# Reject symlink traversal, even if it eventually resolves back inside
|
|
173
|
+
# the project. The file itself must be regular and bounded.
|
|
174
|
+
for i in range(1, len(relative.parts) + 1):
|
|
175
|
+
segment = root.joinpath(*relative.parts[:i])
|
|
176
|
+
if segment.is_symlink():
|
|
177
|
+
raise ProjectError('PCZT symlinks are not permitted.')
|
|
178
|
+
try:
|
|
179
|
+
fd = os.open(lexical, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) |
|
|
180
|
+
getattr(os, 'O_NONBLOCK', 0))
|
|
181
|
+
try:
|
|
182
|
+
meta = os.fstat(fd)
|
|
183
|
+
if not stat.S_ISREG(meta.st_mode) or not 0 < meta.st_size <= 8 * 1024 * 1024:
|
|
184
|
+
raise ProjectError('PCZT must be a regular file of at most 8 MiB.')
|
|
185
|
+
finally:
|
|
186
|
+
os.close(fd)
|
|
187
|
+
except (OSError, ValueError):
|
|
188
|
+
raise ProjectError('Selected PCZT file is unavailable.') from None
|
|
189
|
+
return lexical
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _policy_from_project(root: Path) -> Policy:
|
|
193
|
+
policy_path = _guard(root, POLICY, must_exist=True)
|
|
194
|
+
try:
|
|
195
|
+
fd = os.open(policy_path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) |
|
|
196
|
+
getattr(os, 'O_NONBLOCK', 0))
|
|
197
|
+
try:
|
|
198
|
+
metadata = os.fstat(fd)
|
|
199
|
+
if not stat.S_ISREG(metadata.st_mode) or not 0 < metadata.st_size <= MAX_POLICY_BYTES:
|
|
200
|
+
raise ProjectError('Project policy must be a bounded regular file.')
|
|
201
|
+
with os.fdopen(fd, 'rb', closefd=False) as stream:
|
|
202
|
+
raw = stream.read(MAX_POLICY_BYTES + 1)
|
|
203
|
+
finally:
|
|
204
|
+
os.close(fd)
|
|
205
|
+
def unique(entries: list[tuple[str, object]]) -> dict[str, object]:
|
|
206
|
+
parsed: dict[str, object] = {}
|
|
207
|
+
for key, value in entries:
|
|
208
|
+
if key in parsed:
|
|
209
|
+
raise ValueError('duplicate')
|
|
210
|
+
parsed[key] = value
|
|
211
|
+
return parsed
|
|
212
|
+
data = json.loads(raw, object_pairs_hook=unique,
|
|
213
|
+
parse_constant=lambda _: (_ for _ in ()).throw(ValueError('constant')))
|
|
214
|
+
if (not isinstance(data, dict) or set(data) != {'profile', 'deny_transparent', 'deny_sapling'}
|
|
215
|
+
or data['profile'] not in ('external_signer', 'restricted_handoff')
|
|
216
|
+
or type(data['deny_transparent']) is not bool
|
|
217
|
+
or type(data['deny_sapling']) is not bool):
|
|
218
|
+
raise ValueError('policy')
|
|
219
|
+
return Policy(profile=data['profile'], deny_transparent=data['deny_transparent'],
|
|
220
|
+
deny_sapling=data['deny_sapling'])
|
|
221
|
+
except (OSError, ValueError, TypeError, UnicodeError, ZerynexError):
|
|
222
|
+
raise ProjectError('Project policy is invalid or cannot be read.') from None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _safe_summary(rule_ids: list[str]) -> list[str]:
|
|
226
|
+
# Field paths, arbitrary report explanations and RPC metadata must never
|
|
227
|
+
# escape into terminal output or agent logs. Output only known rule IDs.
|
|
228
|
+
from .rules import bundled_rule_catalog
|
|
229
|
+
catalog = bundled_rule_catalog()
|
|
230
|
+
known = {entry['id'] for entry in catalog['rules']}
|
|
231
|
+
if any(rule not in known for rule in rule_ids):
|
|
232
|
+
raise ProjectError('Rust engine returned an unsupported policy rule.')
|
|
233
|
+
return sorted(set(rule_ids))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def check_project(root: Path, cli: str | None, pczt: str) -> dict[str, object]:
|
|
237
|
+
"""Run one explicitly supplied PCZT through the real local Rust engine."""
|
|
238
|
+
_guard(root, MANIFEST, must_exist=True)
|
|
239
|
+
policy = _policy_from_project(root)
|
|
240
|
+
candidate = _project_file(root, pczt)
|
|
241
|
+
report = ZerynexClient(cli=cli).analyze_file(candidate, policy)
|
|
242
|
+
return {'schema_version': SCHEMA, 'passed': report.passed,
|
|
243
|
+
'input': 'explicit_local_file', 'engine': 'local_rust_cli',
|
|
244
|
+
'finding_count': len(report.findings),
|
|
245
|
+
'rule_ids': _safe_summary([item.rule for item in report.findings]),
|
|
246
|
+
'limitation': 'Structural policy findings only; not a signature, proof, anonymity or payment verification. No raw PCZT fields are returned.'}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def compare_project(root: Path, cli: str | None, before: str, after: str) -> dict[str, object]:
|
|
250
|
+
"""Check explicit stage regression; does not assume equivalent transactions."""
|
|
251
|
+
_guard(root, MANIFEST, must_exist=True)
|
|
252
|
+
policy = _policy_from_project(root)
|
|
253
|
+
baseline = _project_file(root, before)
|
|
254
|
+
candidate = _project_file(root, after)
|
|
255
|
+
if baseline == candidate:
|
|
256
|
+
raise ProjectError('Before and after PCZT files must be distinct.')
|
|
257
|
+
result = ZerynexClient(cli=cli).compare_files(baseline, candidate, policy)
|
|
258
|
+
return {'schema_version': SCHEMA, 'passed': result.passed,
|
|
259
|
+
'input': 'two_explicit_local_files',
|
|
260
|
+
'engine': 'local_rust_cli', 'no_new_findings': result.no_new_findings,
|
|
261
|
+
'before_findings_count': result.before_findings_count,
|
|
262
|
+
'after_findings_count': result.after_findings_count,
|
|
263
|
+
'added_rule_ids': _safe_summary([rule for rule, _ in result.added_findings]),
|
|
264
|
+
'resolved_rule_ids': _safe_summary([rule for rule, _ in result.resolved_findings]),
|
|
265
|
+
'limitation': 'Only selected policy regressions; not equivalent payments, proof validity or safe signing. No raw PCZT fields are returned.'}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main() -> None:
|
|
269
|
+
parser = argparse.ArgumentParser(description='Initialize and verify offline Zerynex integration in another project.')
|
|
270
|
+
actions = parser.add_subparsers(dest='action', required=True)
|
|
271
|
+
for name in ('init', 'verify', 'check', 'compare'):
|
|
272
|
+
sub = actions.add_parser(name)
|
|
273
|
+
sub.add_argument('--project', required=True, help='Existing developer project directory')
|
|
274
|
+
sub.add_argument('--json', action='store_true', help='Sanitized machine-readable summary')
|
|
275
|
+
if name == 'init':
|
|
276
|
+
sub.add_argument('--ci', action='store_true', help='Scaffold pinned synthetic-only GitHub Action')
|
|
277
|
+
else:
|
|
278
|
+
sub.add_argument('--cli', default=os.environ.get('ZERYNEX_CLI'),
|
|
279
|
+
help='Locally installed trusted Rust CLI path')
|
|
280
|
+
if name == 'check':
|
|
281
|
+
sub.add_argument('--pczt', required=True, help='Explicit PCZT path inside this project')
|
|
282
|
+
elif name == 'compare':
|
|
283
|
+
sub.add_argument('--before', required=True, help='Explicit earlier PCZT file')
|
|
284
|
+
sub.add_argument('--after', required=True, help='Explicit later PCZT file')
|
|
285
|
+
args = parser.parse_args()
|
|
286
|
+
try:
|
|
287
|
+
root = _root(args.project)
|
|
288
|
+
if args.action == 'init':
|
|
289
|
+
result = initialize(root, ci=args.ci)
|
|
290
|
+
elif args.action == 'verify':
|
|
291
|
+
result = verify(root, args.cli)
|
|
292
|
+
elif args.action == 'check':
|
|
293
|
+
result = check_project(root, args.cli, args.pczt)
|
|
294
|
+
else:
|
|
295
|
+
result = compare_project(root, args.cli, args.before, args.after)
|
|
296
|
+
except (ProjectError, ZerynexError, OSError, ValueError):
|
|
297
|
+
# Never echo CLI output, selected PCZT bytes or private workspace paths.
|
|
298
|
+
print('Zerynex project setup or local verification was rejected.', file=sys.stderr)
|
|
299
|
+
raise SystemExit(1) from None
|
|
300
|
+
if args.json:
|
|
301
|
+
print(json.dumps(result, indent=2))
|
|
302
|
+
else:
|
|
303
|
+
print('Zerynex project', args.action, 'PASS' if result.get('passed', result.get('initialized')) else 'FAIL')
|
|
304
|
+
if args.action == 'init':
|
|
305
|
+
print('Synthetic-only integration created. Use verify with your trusted Rust CLI.')
|
|
306
|
+
elif args.action == 'verify':
|
|
307
|
+
print('Local Rust CLI, synthetic fixture integrity and expected rule behavior checked.')
|
|
308
|
+
else:
|
|
309
|
+
print('Explicit local PCZT policy report: no sensitive transaction content included.')
|
|
310
|
+
raise SystemExit(0 if result.get('passed', result.get('initialized')) else 2)
|
|
311
|
+
|
|
312
|
+
if __name__ == '__main__':
|
|
313
|
+
main()
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Offline, metadata-only refund linkability checks for Zcash swap integrations.
|
|
2
|
+
|
|
3
|
+
This intentionally does NOT process Zcash addresses, viewing keys, spend keys,
|
|
4
|
+
refund payments, PIR queries, or cryptographic secrets. References in the
|
|
5
|
+
input are developer-supplied random 128-bit opaque tokens, not hashes of keys.
|
|
6
|
+
Reports contain only fixed rule identifiers and aggregate counts.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import stat
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
MAX_INPUT = 64 * 1024
|
|
20
|
+
MAX_ENTRIES = 100
|
|
21
|
+
TOKEN = re.compile(r'^[0-9a-f]{32}$')
|
|
22
|
+
RULES = (
|
|
23
|
+
'SWAP-REFUND-ADDRESS-REUSE',
|
|
24
|
+
'SWAP-VIEWING-SCOPE-REUSE',
|
|
25
|
+
'SWAP-PUBLIC-REFUND-UNSCOPED',
|
|
26
|
+
'SWAP-REFUND-LOOKUP-DISCLOSURE',
|
|
27
|
+
'SWAP-SPENDING-KEY-CUSTODY-UNVERIFIED',
|
|
28
|
+
)
|
|
29
|
+
LIMITATION = ('Static, self-declared integration metadata only. No viewing-key cryptography, quantum-security, PIR, '
|
|
30
|
+
'wallet behavior, chain activity, or safe recovery is verified. Do not provide addresses or keys.')
|
|
31
|
+
|
|
32
|
+
class RefundAuditInputError(ValueError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _record(obj: object, keys: set[str]) -> dict[str, object]:
|
|
37
|
+
if type(obj) is not dict or set(obj) != keys:
|
|
38
|
+
raise RefundAuditInputError('Invalid privacy exposure manifest shape.')
|
|
39
|
+
return obj
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _token(value: object) -> str:
|
|
43
|
+
if type(value) is not str or not TOKEN.fullmatch(value):
|
|
44
|
+
raise RefundAuditInputError('Expected an opaque 128-bit random reference, not an address or key.')
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _evaluate_manifest(manifest: object) -> tuple[dict[str, object], set[tuple[str, str]]]:
|
|
49
|
+
"""Evaluate declarations while retaining opaque in-memory event identity."""
|
|
50
|
+
root = _record(manifest, {'schema_version', 'flows'})
|
|
51
|
+
if type(root['schema_version']) is not int or root['schema_version'] != 1:
|
|
52
|
+
raise RefundAuditInputError('Unsupported refund audit schema.')
|
|
53
|
+
flows = root['flows']
|
|
54
|
+
if type(flows) is not list or not 1 <= len(flows) <= MAX_ENTRIES:
|
|
55
|
+
raise RefundAuditInputError('Expected 1 to 100 application-declared swap flows.')
|
|
56
|
+
normalized: list[dict[str, Any]] = []
|
|
57
|
+
seen_flow: set[str] = set()
|
|
58
|
+
keyset = {'flow_ref', 'refund_address_ref', 'viewing_scope_ref',
|
|
59
|
+
'published_publicly', 'lookup_mode', 'spending_key_custody'}
|
|
60
|
+
for item in flows:
|
|
61
|
+
row = _record(item, keyset)
|
|
62
|
+
refs = {k: _token(row[k]) for k in ('flow_ref', 'refund_address_ref', 'viewing_scope_ref')}
|
|
63
|
+
if refs['flow_ref'] in seen_flow:
|
|
64
|
+
raise RefundAuditInputError('Duplicate swap-flow record.')
|
|
65
|
+
seen_flow.add(refs['flow_ref'])
|
|
66
|
+
if type(row['published_publicly']) is not bool:
|
|
67
|
+
raise RefundAuditInputError('Expected an explicit public-disclosure boolean.')
|
|
68
|
+
if row['lookup_mode'] not in ('private_claimed', 'direct', 'unknown') or type(row['lookup_mode']) is not str:
|
|
69
|
+
raise RefundAuditInputError('Invalid wallet refund-lookup declaration.')
|
|
70
|
+
if row['spending_key_custody'] not in ('wallet_claimed', 'external', 'unknown') or type(row['spending_key_custody']) is not str:
|
|
71
|
+
raise RefundAuditInputError('Invalid spending-key custody declaration.')
|
|
72
|
+
normalized.append({**refs, 'published_publicly': row['published_publicly'],
|
|
73
|
+
'lookup_mode': row['lookup_mode'], 'spending_key_custody': row['spending_key_custody']})
|
|
74
|
+
|
|
75
|
+
address_count: dict[str, int] = {}
|
|
76
|
+
scope_count: dict[str, int] = {}
|
|
77
|
+
for row in normalized:
|
|
78
|
+
address_count[row['refund_address_ref']] = address_count.get(row['refund_address_ref'], 0) + 1
|
|
79
|
+
scope_count[row['viewing_scope_ref']] = scope_count.get(row['viewing_scope_ref'], 0) + 1
|
|
80
|
+
findings: set[str] = set()
|
|
81
|
+
# Opaque flow references are intentionally never returned in a report.
|
|
82
|
+
events: set[tuple[str, str]] = set()
|
|
83
|
+
for row in normalized:
|
|
84
|
+
def flag(rule: str) -> None:
|
|
85
|
+
findings.add(rule)
|
|
86
|
+
events.add((row['flow_ref'], rule))
|
|
87
|
+
|
|
88
|
+
if address_count[row['refund_address_ref']] > 1:
|
|
89
|
+
flag('SWAP-REFUND-ADDRESS-REUSE')
|
|
90
|
+
if scope_count[row['viewing_scope_ref']] > 1:
|
|
91
|
+
flag('SWAP-VIEWING-SCOPE-REUSE')
|
|
92
|
+
if row['published_publicly'] and scope_count[row['viewing_scope_ref']] > 1:
|
|
93
|
+
flag('SWAP-PUBLIC-REFUND-UNSCOPED')
|
|
94
|
+
if row['lookup_mode'] != 'private_claimed':
|
|
95
|
+
flag('SWAP-REFUND-LOOKUP-DISCLOSURE')
|
|
96
|
+
if row['spending_key_custody'] != 'wallet_claimed':
|
|
97
|
+
flag('SWAP-SPENDING-KEY-CUSTODY-UNVERIFIED')
|
|
98
|
+
return {
|
|
99
|
+
'schema_version': 1, 'passed': not findings, 'flows_checked': len(normalized),
|
|
100
|
+
'public_refund_flows': sum(bool(row['published_publicly']) for row in normalized),
|
|
101
|
+
'finding_count': len(findings), 'rule_ids': sorted(findings),
|
|
102
|
+
'status': 'declaration_review_only', 'limitation': LIMITATION,
|
|
103
|
+
}, events
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def audit_manifest(manifest: object) -> dict[str, object]:
|
|
107
|
+
"""Sanitized, single-manifest privacy-declaration review."""
|
|
108
|
+
return _evaluate_manifest(manifest)[0]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def compare_manifests(baseline: object, current: object) -> dict[str, object]:
|
|
112
|
+
"""Diff per-flow risk events without ever exporting opaque references.
|
|
113
|
+
|
|
114
|
+
A rule already present in the baseline can still be *new* on another
|
|
115
|
+
flow. New flow IDs are treated as new integration declarations.
|
|
116
|
+
"""
|
|
117
|
+
before, old_events = _evaluate_manifest(baseline)
|
|
118
|
+
after, new_events = _evaluate_manifest(current)
|
|
119
|
+
added = new_events - old_events
|
|
120
|
+
resolved = old_events - new_events
|
|
121
|
+
return {
|
|
122
|
+
'schema_version': 1,
|
|
123
|
+
'passed': not added,
|
|
124
|
+
'status': 'declaration_regression_review_only',
|
|
125
|
+
'baseline_flows_checked': before['flows_checked'],
|
|
126
|
+
'current_flows_checked': after['flows_checked'],
|
|
127
|
+
'baseline_risk_events': len(old_events),
|
|
128
|
+
'current_risk_events': len(new_events),
|
|
129
|
+
'new_risk_events': len(added),
|
|
130
|
+
'resolved_risk_events': len(resolved),
|
|
131
|
+
'new_rule_ids': sorted({rule for _, rule in added}),
|
|
132
|
+
'resolved_rule_ids': sorted({rule for _, rule in resolved}),
|
|
133
|
+
'limitation': (
|
|
134
|
+
'Only application-declared logical flow identity and static risk changes. '
|
|
135
|
+
'It cannot attest stable wallet identity, viewing-key isolation, private lookup, '
|
|
136
|
+
'quantum resistance or transaction privacy. References are never returned.'
|
|
137
|
+
),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _unique_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
142
|
+
values: dict[str, object] = {}
|
|
143
|
+
for key, value in pairs:
|
|
144
|
+
if key in values:
|
|
145
|
+
raise RefundAuditInputError('Duplicate JSON field.')
|
|
146
|
+
values[key] = value
|
|
147
|
+
return values
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_manifest(path: str) -> dict[str, object]:
|
|
151
|
+
target = Path(path)
|
|
152
|
+
if target.is_symlink():
|
|
153
|
+
raise RefundAuditInputError('Symlinks are not supported.')
|
|
154
|
+
try:
|
|
155
|
+
flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
|
|
156
|
+
fd = os.open(target, flags)
|
|
157
|
+
try:
|
|
158
|
+
meta = os.fstat(fd)
|
|
159
|
+
if not stat.S_ISREG(meta.st_mode) or meta.st_size <= 0 or meta.st_size > MAX_INPUT:
|
|
160
|
+
raise RefundAuditInputError('Manifest is not a bounded regular file.')
|
|
161
|
+
with os.fdopen(fd, 'rb', closefd=False) as stream:
|
|
162
|
+
raw = stream.read(MAX_INPUT + 1)
|
|
163
|
+
if len(raw) > MAX_INPUT:
|
|
164
|
+
raise RefundAuditInputError('Manifest exceeds file limit.')
|
|
165
|
+
finally:
|
|
166
|
+
os.close(fd)
|
|
167
|
+
parsed = json.loads(raw, object_pairs_hook=_unique_pairs,
|
|
168
|
+
parse_constant=lambda _: (_ for _ in ()).throw(RefundAuditInputError('Invalid numeric constant.')))
|
|
169
|
+
if type(parsed) is not dict:
|
|
170
|
+
raise RefundAuditInputError('Manifest must be a JSON object.')
|
|
171
|
+
return parsed
|
|
172
|
+
except (OSError, UnicodeError, ValueError, TypeError):
|
|
173
|
+
raise RefundAuditInputError('Invalid local refund privacy manifest.') from None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def sarif_report(report: dict[str, object]) -> dict[str, object]:
|
|
178
|
+
"""Portable SARIF 2.1.0 rules without source locations or sensitive refs."""
|
|
179
|
+
expected = ('new_rule_ids' if report.get('status') == 'declaration_regression_review_only'
|
|
180
|
+
else 'rule_ids')
|
|
181
|
+
present = report.get(expected)
|
|
182
|
+
if type(present) is not list or any(type(rule) is not str or rule not in RULES for rule in present):
|
|
183
|
+
raise RefundAuditInputError('Invalid internal audit findings.')
|
|
184
|
+
ordered = sorted(set(present))
|
|
185
|
+
return {
|
|
186
|
+
'$schema': 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
187
|
+
'version': '2.1.0',
|
|
188
|
+
'runs': [{
|
|
189
|
+
'tool': {'driver': {
|
|
190
|
+
'name': 'Zerynex Refund Privacy Audit',
|
|
191
|
+
'informationUri': 'https://zerynex.vercel.app/docs/refund-privacy-audit',
|
|
192
|
+
'semanticVersion': '0.1.0',
|
|
193
|
+
'rules': [
|
|
194
|
+
{'id': name,
|
|
195
|
+
'shortDescription': {'text': 'Review declared Zcash swap refund privacy exposure'},
|
|
196
|
+
'helpUri': 'https://zerynex.vercel.app/docs/refund-privacy-audit'}
|
|
197
|
+
for name in RULES
|
|
198
|
+
],
|
|
199
|
+
}},
|
|
200
|
+
'results': [
|
|
201
|
+
{'ruleId': name, 'level': 'warning',
|
|
202
|
+
'message': {'text': 'Application-declared refund privacy risk requires review.'}}
|
|
203
|
+
for name in ordered
|
|
204
|
+
],
|
|
205
|
+
'properties': {
|
|
206
|
+
'declarationOnly': True,
|
|
207
|
+
'mode': 'regression' if expected == 'new_rule_ids' else 'full_audit',
|
|
208
|
+
'warningCount': len(ordered),
|
|
209
|
+
'noInputLocations': True,
|
|
210
|
+
},
|
|
211
|
+
}],
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
def main() -> None:
|
|
215
|
+
parser = argparse.ArgumentParser(description='Offline self-declared swap refund and viewing-key isolation audit.')
|
|
216
|
+
parser.add_argument('--manifest', required=True, help='Current local public-safe metadata JSON, never addresses or keys')
|
|
217
|
+
parser.add_argument('--baseline', help='Optional earlier metadata JSON: fail only on newly introduced per-flow risks')
|
|
218
|
+
output = parser.add_mutually_exclusive_group()
|
|
219
|
+
output.add_argument('--json', action='store_true', help='Sanitized machine-readable report')
|
|
220
|
+
output.add_argument('--sarif', action='store_true', help='Location-free SARIF 2.1.0 warnings')
|
|
221
|
+
args = parser.parse_args()
|
|
222
|
+
try:
|
|
223
|
+
if args.baseline:
|
|
224
|
+
before = read_manifest(args.baseline)
|
|
225
|
+
after = read_manifest(args.manifest)
|
|
226
|
+
try:
|
|
227
|
+
same = os.path.samefile(args.baseline, args.manifest)
|
|
228
|
+
except OSError:
|
|
229
|
+
raise RefundAuditInputError('Manifest files are unavailable.') from None
|
|
230
|
+
if same:
|
|
231
|
+
raise RefundAuditInputError('Baseline and current file must differ.')
|
|
232
|
+
report = compare_manifests(before, after)
|
|
233
|
+
else:
|
|
234
|
+
report = audit_manifest(read_manifest(args.manifest))
|
|
235
|
+
except RefundAuditInputError:
|
|
236
|
+
print('Refund privacy metadata rejected; no input contents were printed.', file=sys.stderr)
|
|
237
|
+
raise SystemExit(1) from None
|
|
238
|
+
if args.json:
|
|
239
|
+
print(json.dumps(report, indent=2))
|
|
240
|
+
elif args.sarif:
|
|
241
|
+
print(json.dumps(sarif_report(report), indent=2))
|
|
242
|
+
else:
|
|
243
|
+
if args.baseline:
|
|
244
|
+
print('Refund privacy regression:', 'NO NEW DECLARED RISKS' if report['passed'] else 'NEW RISKS DETECTED')
|
|
245
|
+
print('New events:', report['new_risk_events'], 'Rule IDs:', ', '.join(report['new_rule_ids']))
|
|
246
|
+
else:
|
|
247
|
+
print('Refund privacy metadata:', 'NO DECLARED RISKS' if report['passed'] else 'REVIEW REQUIRED')
|
|
248
|
+
print('Flows:', report['flows_checked'], 'Rule IDs:', ', '.join(report['rule_ids']))
|
|
249
|
+
raise SystemExit(0 if report['passed'] else 2)
|
|
250
|
+
|
|
251
|
+
if __name__ == '__main__':
|
|
252
|
+
main()
|