dynamicfeed-verify 1.0.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.
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dynamicfeed-verify — verify Dynamic Feed (DF-VERIFY/1) Ed25519-signed responses, independently.
|
|
3
|
+
|
|
4
|
+
A signed response carries a top-level ``signature`` block. This library reproduces the DF-VERIFY/1
|
|
5
|
+
canonical form (JSON, keys sorted recursively, compact separators), fetches the public key published
|
|
6
|
+
at ``<base>/.well-known/keys``, and verifies the detached Ed25519 signature. If it verifies, the
|
|
7
|
+
response provably came from the issuer and has not been altered — checkable by anyone, even against us.
|
|
8
|
+
|
|
9
|
+
Spec: https://dynamicfeed.ai/standard
|
|
10
|
+
|
|
11
|
+
from dynamicfeed_verify import verify, verify_live
|
|
12
|
+
env, result = verify_live() # fetch a fresh signed verdict + verify it
|
|
13
|
+
assert result["ok"]
|
|
14
|
+
verify(my_signed_response) # verify any signed response you already hold
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import urllib.request
|
|
21
|
+
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
23
|
+
|
|
24
|
+
__version__ = "1.0.0"
|
|
25
|
+
DEFAULT_BASE = "https://dynamicfeed.ai"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _b64d(s: str) -> bytes:
|
|
29
|
+
"""Decode base64url, tolerating missing padding."""
|
|
30
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical(payload: dict) -> bytes:
|
|
34
|
+
"""DF-VERIFY/1 canonical bytes for ``payload`` (the response WITHOUT its ``signature`` field):
|
|
35
|
+
JSON with object keys sorted recursively and compact separators, encoded UTF-8."""
|
|
36
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch_keys(base: str = DEFAULT_BASE, timeout: float = 20) -> dict:
|
|
40
|
+
"""Fetch the JWKS-style public-key map: ``{key_id: base64url(Ed25519 public key)}``."""
|
|
41
|
+
with urllib.request.urlopen(base.rstrip("/") + "/.well-known/keys", timeout=timeout) as r:
|
|
42
|
+
return json.load(r)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verify(envelope: dict, jwks: dict | None = None, base: str = DEFAULT_BASE) -> dict:
|
|
46
|
+
"""Verify a DF-VERIFY/1 signed response.
|
|
47
|
+
|
|
48
|
+
Returns ``{"ok": bool, "key_id"?, "verdict"?, "snapshot_id"?, "ephemeral"?, "error"?}``.
|
|
49
|
+
Pass ``jwks`` to verify fully offline; otherwise the public key is fetched from ``base``.
|
|
50
|
+
"""
|
|
51
|
+
sig = (envelope or {}).get("signature") or {}
|
|
52
|
+
kid, sig_b64 = sig.get("key_id"), sig.get("sig")
|
|
53
|
+
if not kid or not sig_b64:
|
|
54
|
+
return {"ok": False, "error": "no signature block (need signature.key_id + signature.sig)"}
|
|
55
|
+
payload = {k: v for k, v in envelope.items() if k != "signature"}
|
|
56
|
+
keys = jwks if jwks is not None else fetch_keys(base)
|
|
57
|
+
if kid not in keys:
|
|
58
|
+
return {"ok": False, "key_id": kid, "error": f"key_id {kid} not in JWKS (rotated or ephemeral)"}
|
|
59
|
+
try:
|
|
60
|
+
Ed25519PublicKey.from_public_bytes(_b64d(keys[kid])).verify(_b64d(sig_b64), canonical(payload))
|
|
61
|
+
except Exception as e: # noqa: BLE001 — any failure means it did not verify
|
|
62
|
+
return {"ok": False, "key_id": kid, "error": f"signature INVALID: {e}"}
|
|
63
|
+
return {
|
|
64
|
+
"ok": True, "key_id": kid, "ephemeral": bool(sig.get("ephemeral_key")),
|
|
65
|
+
"verdict": (envelope.get("verdict") or {}).get("status"),
|
|
66
|
+
"snapshot_id": envelope.get("snapshot_id"),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def verify_live(base: str = DEFAULT_BASE, robot: dict | None = None, location: dict | None = None):
|
|
71
|
+
"""Fetch a fresh signed awareness verdict from ``base`` and verify it. Returns ``(envelope, result)``."""
|
|
72
|
+
body = {"robot": robot or {"class": "aerial"}, "location": location or {"lat": 51.5, "lon": -0.12}}
|
|
73
|
+
req = urllib.request.Request(
|
|
74
|
+
base.rstrip("/") + "/v1/awareness",
|
|
75
|
+
data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
|
|
76
|
+
with urllib.request.urlopen(req, timeout=25) as r:
|
|
77
|
+
env = json.load(r)
|
|
78
|
+
return env, verify(env, base=base)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""CLI: ``dynamicfeed-verify`` — fetch a live signed verdict and verify it, or verify a saved response."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import DEFAULT_BASE, verify, verify_live
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> int:
|
|
11
|
+
args = sys.argv[1:]
|
|
12
|
+
if args and args[0] in ("-h", "--help"):
|
|
13
|
+
print("usage:\n"
|
|
14
|
+
" dynamicfeed-verify [BASE_URL] fetch a live signed verdict and verify it\n"
|
|
15
|
+
" dynamicfeed-verify - < response.json verify a saved signed response (key still fetched)\n"
|
|
16
|
+
" default BASE_URL = https://dynamicfeed.ai · spec: https://dynamicfeed.ai/standard")
|
|
17
|
+
return 0
|
|
18
|
+
if args and args[0] == "-":
|
|
19
|
+
env = json.load(sys.stdin)
|
|
20
|
+
base = args[1].rstrip("/") if len(args) > 1 else DEFAULT_BASE
|
|
21
|
+
res = verify(env, base=base)
|
|
22
|
+
else:
|
|
23
|
+
base = args[0].rstrip("/") if args else DEFAULT_BASE
|
|
24
|
+
print(f"requesting a live signed verdict from {base}/v1/awareness ...")
|
|
25
|
+
_env, res = verify_live(base)
|
|
26
|
+
if res.get("ok"):
|
|
27
|
+
extra = ""
|
|
28
|
+
if res.get("verdict"):
|
|
29
|
+
extra += f" · verdict={res['verdict']}"
|
|
30
|
+
if res.get("snapshot_id"):
|
|
31
|
+
extra += f" · snapshot={res['snapshot_id']}"
|
|
32
|
+
if res.get("ephemeral"):
|
|
33
|
+
extra += " · EPHEMERAL key"
|
|
34
|
+
print(f"✅ VALID — key={res['key_id']}{extra}")
|
|
35
|
+
return 0
|
|
36
|
+
print(f"✗ INVALID — {res.get('error')}")
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dynamicfeed-verify
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Verify Dynamic Feed (DF-VERIFY/1) Ed25519-signed responses, independently — in one line.
|
|
5
|
+
Project-URL: Homepage, https://dynamicfeed.ai
|
|
6
|
+
Project-URL: Standard, https://dynamicfeed.ai/standard
|
|
7
|
+
Project-URL: Source, https://github.com/dynamicfeed/df-verify
|
|
8
|
+
Author: Dynamic Feed
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,ai,attestation,df-verify,dynamic-feed,ed25519,provenance,verification
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Requires-Dist: cryptography>=41
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# dynamicfeed-verify
|
|
21
|
+
|
|
22
|
+
Verify [Dynamic Feed](https://dynamicfeed.ai) **DF-VERIFY/1** Ed25519-signed responses — independently, in one line. No account, no dependency on Dynamic Feed at runtime beyond fetching the public key. You can verify, even against us.
|
|
23
|
+
|
|
24
|
+
Reference implementation of the [DF-VERIFY/1 standard](https://dynamicfeed.ai/standard).
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install dynamicfeed-verify
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from dynamicfeed_verify import verify, verify_live
|
|
36
|
+
|
|
37
|
+
# 1) fetch a fresh signed awareness verdict and verify it
|
|
38
|
+
env, result = verify_live()
|
|
39
|
+
print(result) # {'ok': True, 'key_id': 'df-ed25519-…', 'verdict': 'caution', ...}
|
|
40
|
+
|
|
41
|
+
# 2) verify any signed response you already hold
|
|
42
|
+
result = verify(signed_response)
|
|
43
|
+
if not result["ok"]:
|
|
44
|
+
raise RuntimeError(result["error"])
|
|
45
|
+
|
|
46
|
+
# 3) verify fully offline if you already have the JWKS
|
|
47
|
+
result = verify(signed_response, jwks={"df-ed25519-…": "<base64url public key>"})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## CLI
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
dynamicfeed-verify # fetch a live verdict + verify
|
|
54
|
+
dynamicfeed-verify - < response.json # verify a saved signed response
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## How it works
|
|
58
|
+
|
|
59
|
+
A signed response carries a `signature` block (`alg`, `key_id`, `canonicalization`, `sig`). Verification:
|
|
60
|
+
|
|
61
|
+
1. Drop the `signature` field; keep the rest as the payload.
|
|
62
|
+
2. Canonicalize — JSON, keys sorted recursively, compact separators (`,` `:`), UTF-8. Equivalent to `json.dumps(payload, sort_keys=True, separators=(",", ":"))`.
|
|
63
|
+
3. Fetch the public key from `https://dynamicfeed.ai/.well-known/keys` and look up `signature.key_id`.
|
|
64
|
+
4. Verify the Ed25519 signature over the canonical bytes. Change one byte → it fails.
|
|
65
|
+
|
|
66
|
+
Full specification: **https://dynamicfeed.ai/standard**
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
dynamicfeed_verify/__init__.py,sha256=yHOYCKm9pUvWjj_cOgCDAeInTRmG8nhgLdOLk8zUPY0,3691
|
|
2
|
+
dynamicfeed_verify/__main__.py,sha256=ewUXlpUN2TJkmj3sbNVK75UlOPJ4QqeXDQK7QnWuBRA,1517
|
|
3
|
+
dynamicfeed_verify-1.0.0.dist-info/METADATA,sha256=45jC5KaQJ5QRhk5mbDKcmRJKEDWS7Ga24AnCjxONlak,2512
|
|
4
|
+
dynamicfeed_verify-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
dynamicfeed_verify-1.0.0.dist-info/entry_points.txt,sha256=87e-MAR6LuqBfcdj3qocdeZITAQ8YEZ8vuvTE35t7W0,72
|
|
6
|
+
dynamicfeed_verify-1.0.0.dist-info/RECORD,,
|