opentrust-cli 1.0.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.
- opentrust_cli-1.0.0/PKG-INFO +48 -0
- opentrust_cli-1.0.0/README.md +29 -0
- opentrust_cli-1.0.0/pyproject.toml +25 -0
- opentrust_cli-1.0.0/setup.cfg +4 -0
- opentrust_cli-1.0.0/src/opentrust_cli/__init__.py +1 -0
- opentrust_cli-1.0.0/src/opentrust_cli/api_client.py +17 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/__init__.py +1 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/badge.py +9 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/claim.py +11 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/inspect.py +10 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/payment.py +30 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/policy.py +183 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/search.py +11 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/status.py +11 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/validate.py +15 -0
- opentrust_cli-1.0.0/src/opentrust_cli/commands/verify.py +234 -0
- opentrust_cli-1.0.0/src/opentrust_cli/formatters.py +29 -0
- opentrust_cli-1.0.0/src/opentrust_cli/main.py +17 -0
- opentrust_cli-1.0.0/src/opentrust_cli/schema_validator.py +161 -0
- opentrust_cli-1.0.0/src/opentrust_cli.egg-info/PKG-INFO +48 -0
- opentrust_cli-1.0.0/src/opentrust_cli.egg-info/SOURCES.txt +27 -0
- opentrust_cli-1.0.0/src/opentrust_cli.egg-info/dependency_links.txt +1 -0
- opentrust_cli-1.0.0/src/opentrust_cli.egg-info/entry_points.txt +2 -0
- opentrust_cli-1.0.0/src/opentrust_cli.egg-info/requires.txt +6 -0
- opentrust_cli-1.0.0/src/opentrust_cli.egg-info/top_level.txt +1 -0
- opentrust_cli-1.0.0/tests/test_cli.py +10 -0
- opentrust_cli-1.0.0/tests/test_policy_spend_file.py +39 -0
- opentrust_cli-1.0.0/tests/test_schema_validator.py +386 -0
- opentrust_cli-1.0.0/tests/test_verify.py +514 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opentrust-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Command-line tools for inspecting, validating, and working with OpenTrust passports
|
|
5
|
+
Author-email: Novel Hut Studios <founder@novelhut.net>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Costder/opentrust
|
|
8
|
+
Project-URL: Repository, https://github.com/Costder/opentrust
|
|
9
|
+
Project-URL: Issues, https://github.com/Costder/opentrust/issues
|
|
10
|
+
Keywords: opentrust,cli,ai-agents,trust-registry,tool-passports
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: typer>=0.25.1
|
|
14
|
+
Requires-Dist: rich>=15.0.0
|
|
15
|
+
Requires-Dist: httpx>=0.28.1
|
|
16
|
+
Requires-Dist: jsonschema>=4.26.0
|
|
17
|
+
Requires-Dist: cryptography>=42
|
|
18
|
+
Requires-Dist: ecdsa>=0.19
|
|
19
|
+
|
|
20
|
+
# opentrust-cli
|
|
21
|
+
|
|
22
|
+
Command-line tools for OpenTrust passports and trust registry workflows.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install opentrust-cli
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
opentrust inspect <tool-id>
|
|
34
|
+
opentrust search <query>
|
|
35
|
+
opentrust status <tool-id>
|
|
36
|
+
opentrust validate <passport.json>
|
|
37
|
+
opentrust claim ...
|
|
38
|
+
opentrust badge <tool-id>
|
|
39
|
+
opentrust payment create-checkout <tool-id>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Repository
|
|
43
|
+
|
|
44
|
+
https://github.com/Costder/opentrust
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# opentrust-cli
|
|
2
|
+
|
|
3
|
+
Command-line tools for OpenTrust passports and trust registry workflows.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install opentrust-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
opentrust inspect <tool-id>
|
|
15
|
+
opentrust search <query>
|
|
16
|
+
opentrust status <tool-id>
|
|
17
|
+
opentrust validate <passport.json>
|
|
18
|
+
opentrust claim ...
|
|
19
|
+
opentrust badge <tool-id>
|
|
20
|
+
opentrust payment create-checkout <tool-id>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Repository
|
|
24
|
+
|
|
25
|
+
https://github.com/Costder/opentrust
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=82", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "opentrust-cli"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Command-line tools for inspecting, validating, and working with OpenTrust passports"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Novel Hut Studios", email = "founder@novelhut.net" }]
|
|
13
|
+
keywords = ["opentrust", "cli", "ai-agents", "trust-registry", "tool-passports"]
|
|
14
|
+
dependencies = ["typer>=0.25.1", "rich>=15.0.0", "httpx>=0.28.1", "jsonschema>=4.26.0", "cryptography>=42", "ecdsa>=0.19"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/Costder/opentrust"
|
|
18
|
+
Repository = "https://github.com/Costder/opentrust"
|
|
19
|
+
Issues = "https://github.com/Costder/opentrust/issues"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
opentrust = "opentrust_cli.main:app"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class APIClient:
|
|
6
|
+
def __init__(self, base_url: str | None = None):
|
|
7
|
+
self.base_url = (base_url or os.getenv("OPENTRUST_API_URL") or "http://localhost:8000").rstrip("/")
|
|
8
|
+
|
|
9
|
+
def get(self, path: str, **params):
|
|
10
|
+
response = httpx.get(f"{self.base_url}/api/v1{path}", params=params, timeout=10)
|
|
11
|
+
response.raise_for_status()
|
|
12
|
+
return response.json()
|
|
13
|
+
|
|
14
|
+
def post(self, path: str, json: dict | None = None):
|
|
15
|
+
response = httpx.post(f"{self.base_url}/api/v1{path}", json=json, timeout=10)
|
|
16
|
+
response.raise_for_status()
|
|
17
|
+
return response.json()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from opentrust_cli.formatters import console
|
|
3
|
+
|
|
4
|
+
app = typer.Typer()
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@app.callback(invoke_without_command=True)
|
|
8
|
+
def badge(slug: str, base_url: str = "https://opentrust.dev"):
|
|
9
|
+
console.print(f"}/api/v1/badge/{slug}.svg)")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from opentrust_cli.api_client import APIClient
|
|
3
|
+
from opentrust_cli.formatters import console
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.callback(invoke_without_command=True)
|
|
9
|
+
def claim(slug: str):
|
|
10
|
+
data = APIClient().post(f"/claim?slug={slug}", json=None)
|
|
11
|
+
console.print(f"Claim {slug}: {data.get('auth_url')}")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from opentrust_cli.api_client import APIClient
|
|
3
|
+
from opentrust_cli.formatters import print_passport
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.callback(invoke_without_command=True)
|
|
9
|
+
def inspect(slug: str):
|
|
10
|
+
print_passport(APIClient().get(f"/tools/{slug}"))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from opentrust_cli.formatters import console
|
|
6
|
+
|
|
7
|
+
app = typer.Typer()
|
|
8
|
+
|
|
9
|
+
PRICES = {
|
|
10
|
+
"trust_report": Decimal("19.00"),
|
|
11
|
+
"verified_badge": Decimal("49.00"),
|
|
12
|
+
"monitoring_monthly": Decimal("19.00"),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("create-checkout")
|
|
17
|
+
def create_checkout(tool_id: str, plan: str = "verified_badge"):
|
|
18
|
+
amount = PRICES.get(plan, PRICES["verified_badge"])
|
|
19
|
+
checkout_id = f"chk_{uuid4().hex}"
|
|
20
|
+
console.print(
|
|
21
|
+
{
|
|
22
|
+
"checkout_id": checkout_id,
|
|
23
|
+
"tool_id": tool_id,
|
|
24
|
+
"plan": plan,
|
|
25
|
+
"amount_usdc": str(amount),
|
|
26
|
+
"status": "paid",
|
|
27
|
+
"provider": "mock",
|
|
28
|
+
"checkout_url": f"https://mock.opentrust.local/checkouts/{checkout_id}",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from opentrust_cli.formatters import console
|
|
7
|
+
|
|
8
|
+
app = typer.Typer()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
VALID_TRUST_STATUSES = frozenset({
|
|
15
|
+
"auto_generated_draft", "creator_claimed", "owner_confirmed",
|
|
16
|
+
"community_reviewed", "reviewer_signed", "security_checked",
|
|
17
|
+
"continuously_monitored", "disputed",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
BROAD_DANGEROUS_PERMISSIONS = frozenset({"wallet", "private_data", "terminal"})
|
|
21
|
+
TRUST_ORDER = {
|
|
22
|
+
"auto_generated_draft": 0,
|
|
23
|
+
"creator_claimed": 1,
|
|
24
|
+
"owner_confirmed": 2,
|
|
25
|
+
"community_reviewed": 3,
|
|
26
|
+
"reviewer_signed": 4,
|
|
27
|
+
"security_checked": 5,
|
|
28
|
+
"continuously_monitored": 6,
|
|
29
|
+
}
|
|
30
|
+
DEFAULT_SPEND_POLICY = {
|
|
31
|
+
"max_cost_per_call_usdc": 999999.0,
|
|
32
|
+
"min_trust_status": "community_reviewed",
|
|
33
|
+
"blocked_permissions": ["wallet", "private_data", "terminal"],
|
|
34
|
+
"allowed_networks": ["base"],
|
|
35
|
+
"allowed_currencies": ["USDC"],
|
|
36
|
+
"require_escrow_above_usdc": 0.10,
|
|
37
|
+
"human_approval_above_usdc": 0.01,
|
|
38
|
+
}
|
|
39
|
+
# Thresholds in USDC — amounts at or below these are "free/safe"
|
|
40
|
+
ESCROW_THRESHOLD_USDC = 0.10 # above this, escrow must be available
|
|
41
|
+
HUMAN_APPROVAL_THRESHOLD_USDC = 0.01 # above this, human approval or escrow needed
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_json(path_str: str) -> dict:
|
|
45
|
+
path = Path(path_str)
|
|
46
|
+
if not path.exists():
|
|
47
|
+
raise typer.BadParameter(f"File not found: {path_str}")
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(path.read_text())
|
|
50
|
+
except json.JSONDecodeError as exc:
|
|
51
|
+
raise typer.BadParameter(
|
|
52
|
+
f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_amount(passport: dict) -> float:
|
|
57
|
+
"""Extract the payment amount from commercial_status, defaulting to 0."""
|
|
58
|
+
cs = passport.get("commercial_status") or {}
|
|
59
|
+
pricing = cs.get("pricing") if isinstance(cs, dict) else {}
|
|
60
|
+
if isinstance(pricing, dict):
|
|
61
|
+
return float(pricing.get("amount", 0))
|
|
62
|
+
return 0.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _has_escrow(passport: dict) -> bool:
|
|
66
|
+
"""Check if the passport has escrow_config with supported=True."""
|
|
67
|
+
cs = passport.get("commercial_status") or {}
|
|
68
|
+
if not isinstance(cs, dict):
|
|
69
|
+
return False
|
|
70
|
+
escrow = cs.get("escrow_config")
|
|
71
|
+
if isinstance(escrow, dict):
|
|
72
|
+
return escrow.get("supported") is True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _payment_config(passport: dict) -> dict:
|
|
77
|
+
cs = passport.get("commercial_status") or {}
|
|
78
|
+
config = cs.get("payment_config") if isinstance(cs, dict) else None
|
|
79
|
+
return config if isinstance(config, dict) else {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _pricing(passport: dict) -> dict:
|
|
83
|
+
cs = passport.get("commercial_status") or {}
|
|
84
|
+
pricing = cs.get("pricing") if isinstance(cs, dict) else None
|
|
85
|
+
return pricing if isinstance(pricing, dict) else {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── Policy check command ─────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def check(
|
|
93
|
+
passport_path: str = typer.Argument(..., help="Path to passport JSON file"),
|
|
94
|
+
policy_path: str | None = typer.Option(None, "--policy", help="Path to local spend policy JSON"),
|
|
95
|
+
):
|
|
96
|
+
"""Check a passport against local agent policy rules.
|
|
97
|
+
|
|
98
|
+
Denies:
|
|
99
|
+
- Disputed or inline-revoked passports
|
|
100
|
+
- Unknown or unparseable trust_status
|
|
101
|
+
- Broad boolean true for wallet / private_data / terminal permissions
|
|
102
|
+
- Payment above threshold without escrow or human approval
|
|
103
|
+
"""
|
|
104
|
+
passport = _load_json(passport_path)
|
|
105
|
+
spend_policy = DEFAULT_SPEND_POLICY | (_load_json(policy_path) if policy_path else {})
|
|
106
|
+
denials: list[str] = []
|
|
107
|
+
|
|
108
|
+
# ── 1. Trust status checks ───────────────────────────────────────────
|
|
109
|
+
trust_status = passport.get("trust_status", "")
|
|
110
|
+
|
|
111
|
+
if trust_status not in VALID_TRUST_STATUSES:
|
|
112
|
+
denials.append(
|
|
113
|
+
f"INVALID TRUST STATUS: '{trust_status}' is not a recognized "
|
|
114
|
+
f"trust_status value"
|
|
115
|
+
)
|
|
116
|
+
elif trust_status == "disputed":
|
|
117
|
+
denials.append("DISPUTED: passport trust_status is 'disputed' — denied by policy")
|
|
118
|
+
else:
|
|
119
|
+
minimum = spend_policy.get("min_trust_status", "community_reviewed")
|
|
120
|
+
if TRUST_ORDER.get(trust_status, -1) < TRUST_ORDER.get(minimum, 99):
|
|
121
|
+
denials.append(
|
|
122
|
+
f"TRUST TOO LOW: '{trust_status}' is below required min_trust_status '{minimum}'"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# ── 2. Inline revocation check ───────────────────────────────────────
|
|
126
|
+
revocation = passport.get("revocation") or {}
|
|
127
|
+
if revocation.get("revoked") is True:
|
|
128
|
+
reason = revocation.get("reason", "unspecified")
|
|
129
|
+
denials.append(f"REVOKED: passport is revoked (reason: {reason}) — denied by policy")
|
|
130
|
+
|
|
131
|
+
# ── 3. Broad dangerous permission check ──────────────────────────────
|
|
132
|
+
perm_manifest = passport.get("permission_manifest") or {}
|
|
133
|
+
blocked_permissions = set(spend_policy.get("blocked_permissions") or BROAD_DANGEROUS_PERMISSIONS)
|
|
134
|
+
for perm in blocked_permissions:
|
|
135
|
+
if perm_manifest.get(perm) is True:
|
|
136
|
+
denials.append(
|
|
137
|
+
f"BROAD PERMISSION: '{perm}' is set to boolean true — "
|
|
138
|
+
f"policy requires scoped/granular declarations"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# ── 4. Payment threshold checks ──────────────────────────────────────
|
|
142
|
+
amount = _get_amount(passport)
|
|
143
|
+
pricing = _pricing(passport)
|
|
144
|
+
payment_config = _payment_config(passport)
|
|
145
|
+
max_per_call = float(spend_policy.get("max_cost_per_call_usdc", ESCROW_THRESHOLD_USDC))
|
|
146
|
+
if amount > max_per_call:
|
|
147
|
+
denials.append(
|
|
148
|
+
f"SPEND CAP: payment amount ${amount:.2f} exceeds max_cost_per_call_usdc ${max_per_call:.2f}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
currency = pricing.get("currency")
|
|
152
|
+
allowed_currencies = set(spend_policy.get("allowed_currencies") or [])
|
|
153
|
+
if amount > 0 and allowed_currencies and currency not in allowed_currencies:
|
|
154
|
+
denials.append(f"CURRENCY DENIED: '{currency}' is not in allowed_currencies")
|
|
155
|
+
|
|
156
|
+
network = payment_config.get("network") or payment_config.get("chain")
|
|
157
|
+
allowed_networks = set(spend_policy.get("allowed_networks") or [])
|
|
158
|
+
if amount > 0 and allowed_networks and network and network not in allowed_networks:
|
|
159
|
+
denials.append(f"NETWORK DENIED: '{network}' is not in allowed_networks")
|
|
160
|
+
|
|
161
|
+
escrow_threshold = float(spend_policy.get("require_escrow_above_usdc", ESCROW_THRESHOLD_USDC))
|
|
162
|
+
human_threshold = float(spend_policy.get("human_approval_above_usdc", HUMAN_APPROVAL_THRESHOLD_USDC))
|
|
163
|
+
if amount > escrow_threshold and not _has_escrow(passport):
|
|
164
|
+
denials.append(
|
|
165
|
+
f"ESCROW REQUIRED: payment amount ${amount:.2f} exceeds "
|
|
166
|
+
f"${escrow_threshold:.2f} threshold but passport "
|
|
167
|
+
f"does not declare escrow support"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if amount > human_threshold and not _has_escrow(passport):
|
|
171
|
+
denials.append(
|
|
172
|
+
f"HUMAN APPROVAL REQUIRED: payment amount ${amount:.2f} exceeds "
|
|
173
|
+
f"${human_threshold:.2f} threshold — "
|
|
174
|
+
f"escrow or human approval path is required"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# ── Report ───────────────────────────────────────────────────────────
|
|
178
|
+
if denials:
|
|
179
|
+
for d in denials:
|
|
180
|
+
console.print(f"[red]DENY:[/] {d}")
|
|
181
|
+
raise typer.Exit(1)
|
|
182
|
+
|
|
183
|
+
console.print(f"[green]ALLOW:[/] {passport_path} — policy checks passed")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from opentrust_cli.api_client import APIClient
|
|
3
|
+
from opentrust_cli.formatters import console
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.callback(invoke_without_command=True)
|
|
9
|
+
def search(q: str):
|
|
10
|
+
for item in APIClient().get("/search", q=q):
|
|
11
|
+
console.print(f"[bold]{item['name']}[/] {item['trust_status']} /tools/{item['slug']}")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from opentrust_cli.api_client import APIClient
|
|
3
|
+
from opentrust_cli.formatters import console
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.callback(invoke_without_command=True)
|
|
9
|
+
def status(slug: str):
|
|
10
|
+
data = APIClient().get(f"/tools/{slug}")
|
|
11
|
+
console.print(f"[bold]{slug}[/]: [{data['trust_status']}]{data['trust_status']}[/]")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from opentrust_cli.formatters import console
|
|
3
|
+
from opentrust_cli.schema_validator import validate_passport_file
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.callback(invoke_without_command=True)
|
|
9
|
+
def validate(path: str):
|
|
10
|
+
errors = validate_passport_file(path)
|
|
11
|
+
if errors:
|
|
12
|
+
for error in errors:
|
|
13
|
+
console.print(f"[red]invalid:[/] {error}")
|
|
14
|
+
raise typer.Exit(1)
|
|
15
|
+
console.print("[green]valid passport[/]")
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from cryptography.exceptions import InvalidSignature
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
10
|
+
from ecdsa import Ed25519, VerifyingKey
|
|
11
|
+
from ecdsa.keys import BadSignatureError
|
|
12
|
+
|
|
13
|
+
from opentrust_cli.formatters import console
|
|
14
|
+
|
|
15
|
+
app = typer.Typer()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
VALID_TRUST_STATUSES = frozenset({
|
|
19
|
+
"auto_generated_draft", "creator_claimed", "owner_confirmed",
|
|
20
|
+
"community_reviewed", "reviewer_signed", "security_checked",
|
|
21
|
+
"continuously_monitored", "disputed",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _canonical_json(data: dict) -> str:
|
|
26
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sha256_hex(canonical: str) -> str:
|
|
30
|
+
return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _without_path(data: dict[str, Any], path: tuple[str, ...]) -> dict[str, Any]:
|
|
34
|
+
copied = json.loads(json.dumps(data))
|
|
35
|
+
current: Any = copied
|
|
36
|
+
for part in path[:-1]:
|
|
37
|
+
if not isinstance(current, dict):
|
|
38
|
+
return copied
|
|
39
|
+
current = current.get(part, {})
|
|
40
|
+
if isinstance(current, dict):
|
|
41
|
+
current.pop(path[-1], None)
|
|
42
|
+
return copied
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_json(path_str: str) -> dict:
|
|
46
|
+
path = Path(path_str)
|
|
47
|
+
if not path.exists():
|
|
48
|
+
raise typer.BadParameter(f"File not found: {path_str}")
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(path.read_text())
|
|
51
|
+
except json.JSONDecodeError as exc:
|
|
52
|
+
raise typer.BadParameter(
|
|
53
|
+
f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _find_key(keys_data: dict, key_id: str) -> dict | None:
|
|
58
|
+
for key in keys_data.get("keys", []):
|
|
59
|
+
if key.get("key_id") == key_id or key.get("kid") == key_id:
|
|
60
|
+
return key
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _b64decode(value: str) -> bytes:
|
|
65
|
+
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _key_public_value(key_record: dict) -> str:
|
|
69
|
+
return key_record.get("public_key") or key_record.get("x") or ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _signature_value(sig: dict) -> str:
|
|
73
|
+
return sig.get("value") or sig.get("signature") or ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _verify_ed25519_digest(public_key_b64: str, signature_b64: str, digest: bytes) -> bool:
|
|
77
|
+
"""Verify legacy OpenTrust tests: ecdsa package signs raw sha256 digest bytes."""
|
|
78
|
+
try:
|
|
79
|
+
vk = VerifyingKey.from_string(_b64decode(public_key_b64), curve=Ed25519)
|
|
80
|
+
vk.verify(_b64decode(signature_b64), digest)
|
|
81
|
+
return True
|
|
82
|
+
except (BadSignatureError, ValueError, Exception):
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _verify_ed25519_message(public_key_b64: str, signature_b64: str, message: bytes) -> bool:
|
|
87
|
+
"""Verify production OpenTrust signatures: cryptography signs payload_hash text."""
|
|
88
|
+
try:
|
|
89
|
+
public_key = Ed25519PublicKey.from_public_bytes(_b64decode(public_key_b64))
|
|
90
|
+
public_key.verify(_b64decode(signature_b64), message)
|
|
91
|
+
return True
|
|
92
|
+
except (InvalidSignature, ValueError, Exception):
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _verify_signature_block(document: dict, signature_path: tuple[str, ...], keys_data: dict) -> list[str]:
|
|
97
|
+
errors: list[str] = []
|
|
98
|
+
current: Any = document
|
|
99
|
+
for part in signature_path:
|
|
100
|
+
if not isinstance(current, dict) or part not in current:
|
|
101
|
+
return [f"MISSING SIGNATURE: {'.'.join(signature_path)}"]
|
|
102
|
+
current = current[part]
|
|
103
|
+
sig = current
|
|
104
|
+
if not isinstance(sig, dict):
|
|
105
|
+
return [f"INVALID SIGNATURE BLOCK: {'.'.join(signature_path)}"]
|
|
106
|
+
|
|
107
|
+
candidates = [_without_path(document, signature_path)]
|
|
108
|
+
# Older passport signatures removed the whole top-level security object
|
|
109
|
+
# before hashing. Production signatures remove only security.registry_signature.
|
|
110
|
+
if signature_path == ("security", "registry_signature"):
|
|
111
|
+
candidates.append(_without_path(document, ("security",)))
|
|
112
|
+
|
|
113
|
+
expected_hash = sig.get("payload_hash", "")
|
|
114
|
+
matched_hash = ""
|
|
115
|
+
for unsigned in candidates:
|
|
116
|
+
candidate_hash = _sha256_hex(_canonical_json(unsigned))
|
|
117
|
+
if candidate_hash == expected_hash:
|
|
118
|
+
matched_hash = candidate_hash
|
|
119
|
+
break
|
|
120
|
+
if not matched_hash:
|
|
121
|
+
computed = _sha256_hex(_canonical_json(candidates[0]))
|
|
122
|
+
errors.append(
|
|
123
|
+
f"PAYLOAD HASH MISMATCH: expected '{expected_hash}', computed '{computed}'"
|
|
124
|
+
)
|
|
125
|
+
matched_hash = computed
|
|
126
|
+
|
|
127
|
+
key_id = sig.get("key_id") or sig.get("kid") or ""
|
|
128
|
+
key_record = _find_key(keys_data, key_id)
|
|
129
|
+
if not key_record:
|
|
130
|
+
errors.append(f"KEY NOT FOUND: no key with key_id '{key_id}' in keys file")
|
|
131
|
+
return errors
|
|
132
|
+
|
|
133
|
+
public_key_b64 = _key_public_value(key_record)
|
|
134
|
+
signature_b64 = _signature_value(sig)
|
|
135
|
+
if not signature_b64:
|
|
136
|
+
errors.append("MISSING SIGNATURE VALUE")
|
|
137
|
+
return errors
|
|
138
|
+
|
|
139
|
+
digest = bytes.fromhex(matched_hash[len("sha256:"):])
|
|
140
|
+
valid = _verify_ed25519_message(public_key_b64, signature_b64, matched_hash.encode("utf-8"))
|
|
141
|
+
valid = valid or _verify_ed25519_digest(public_key_b64, signature_b64, digest)
|
|
142
|
+
if not valid:
|
|
143
|
+
errors.append(f"INVALID SIGNATURE: Ed25519 signature verification failed (key_id: {key_id})")
|
|
144
|
+
return errors
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class RevocationVersionStore:
|
|
148
|
+
"""Tiny local JSON store for revocation monotonic-version rollback checks."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
151
|
+
self.path = Path(path or Path.home() / ".opentrust" / "revocation_versions.json")
|
|
152
|
+
|
|
153
|
+
def load(self) -> dict[str, int]:
|
|
154
|
+
if not self.path.exists():
|
|
155
|
+
return {}
|
|
156
|
+
try:
|
|
157
|
+
return json.loads(self.path.read_text())
|
|
158
|
+
except json.JSONDecodeError:
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
def save(self, versions: dict[str, int]) -> None:
|
|
162
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
self.path.write_text(json.dumps(versions, sort_keys=True, indent=2))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _check_revocation_rollback(registry_id: str, revocations_data: dict, store: RevocationVersionStore) -> None:
|
|
167
|
+
version = int(revocations_data.get("version", (revocations_data.get("payload") or {}).get("version", 0)))
|
|
168
|
+
versions = store.load()
|
|
169
|
+
previous = versions.get(registry_id)
|
|
170
|
+
if previous is not None and version < previous:
|
|
171
|
+
raise ValueError(f"revocation rollback detected: version {version} < previous {previous}")
|
|
172
|
+
versions[registry_id] = max(version, previous or 0)
|
|
173
|
+
store.save(versions)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _revocation_entries(revocations_data: dict) -> list[dict]:
|
|
177
|
+
if "passports" in revocations_data:
|
|
178
|
+
return revocations_data.get("passports") or []
|
|
179
|
+
payload = revocations_data.get("payload") or {}
|
|
180
|
+
return payload.get("passports") or payload.get("revoked") or []
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.callback(invoke_without_command=True)
|
|
184
|
+
def verify(
|
|
185
|
+
passport_path: str = typer.Argument(..., help="Path to passport JSON file"),
|
|
186
|
+
keys: str = typer.Option(..., "--keys", help="Path to registry keys JSON file"),
|
|
187
|
+
revocations: str | None = typer.Option(
|
|
188
|
+
None, "--revocations", help="Path to signed revocation list JSON file"
|
|
189
|
+
),
|
|
190
|
+
):
|
|
191
|
+
"""Offline verify a passport's registry signature and revocation status."""
|
|
192
|
+
errors: list[str] = []
|
|
193
|
+
passport = _load_json(passport_path)
|
|
194
|
+
keys_data = _load_json(keys)
|
|
195
|
+
revocations_data = _load_json(revocations) if revocations else None
|
|
196
|
+
|
|
197
|
+
revocation = passport.get("revocation") or {}
|
|
198
|
+
if revocation.get("revoked") is True:
|
|
199
|
+
errors.append(f"INLINE REVOKED: passport is revoked (reason: {revocation.get('reason', 'unspecified')})")
|
|
200
|
+
|
|
201
|
+
trust_status = passport.get("trust_status", "")
|
|
202
|
+
if trust_status == "disputed":
|
|
203
|
+
errors.append("DISPUTED: passport trust_status is 'disputed'")
|
|
204
|
+
|
|
205
|
+
errors.extend(_verify_signature_block(passport, ("security", "registry_signature"), keys_data))
|
|
206
|
+
|
|
207
|
+
if revocations_data:
|
|
208
|
+
try:
|
|
209
|
+
_check_revocation_rollback("default", revocations_data, RevocationVersionStore())
|
|
210
|
+
except ValueError as exc:
|
|
211
|
+
errors.append(f"REVOCATION ROLLBACK: {exc}")
|
|
212
|
+
|
|
213
|
+
errors.extend(_verify_signature_block(revocations_data, ("signature",), keys_data))
|
|
214
|
+
slug = (passport.get("tool_identity") or {}).get("slug", "")
|
|
215
|
+
version = (passport.get("version_hash") or {}).get("version", "")
|
|
216
|
+
for entry in _revocation_entries(revocations_data):
|
|
217
|
+
entry_slug = entry.get("slug") or entry.get("passport_id") or ""
|
|
218
|
+
entry_version = entry.get("version", "*")
|
|
219
|
+
if entry_slug == slug and (entry_version == version or entry_version == "*"):
|
|
220
|
+
errors.append(
|
|
221
|
+
f"REVOKED: passport '{slug}:{version}' is in the revocation list "
|
|
222
|
+
f"(reason: {entry.get('reason', 'unspecified')})"
|
|
223
|
+
)
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
_report_results(passport_path, errors)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _report_results(passport_path: str, errors: list[str]):
|
|
230
|
+
if errors:
|
|
231
|
+
for err in errors:
|
|
232
|
+
console.print(f"[red]FAIL:[/] {err}")
|
|
233
|
+
raise typer.Exit(1)
|
|
234
|
+
console.print(f"[green]VERIFIED:[/] {passport_path} — registry signature valid, no revocations found")
|