openentity 0.4.0__tar.gz → 0.5.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.
- {openentity-0.4.0 → openentity-0.5.0}/PKG-INFO +3 -1
- {openentity-0.4.0 → openentity-0.5.0}/README.md +2 -0
- {openentity-0.4.0 → openentity-0.5.0}/openentity/__init__.py +2 -1
- openentity-0.5.0/openentity/httpsig.py +110 -0
- {openentity-0.4.0 → openentity-0.5.0}/pyproject.toml +1 -1
- {openentity-0.4.0 → openentity-0.5.0}/tests/test_core.py +1 -1
- openentity-0.5.0/tests/test_httpsig.py +116 -0
- {openentity-0.4.0 → openentity-0.5.0}/uv.lock +1 -1
- {openentity-0.4.0 → openentity-0.5.0}/.gitignore +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/openentity/cli.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/openentity/client.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/openentity/core.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/openentity/identity.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/openentity/issuer.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/openentity/report.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/tests/test_client.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/tests/test_identity.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/tests/test_issuer.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/tests/test_trust.py +0 -0
- {openentity-0.4.0 → openentity-0.5.0}/tests/test_verifier.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openentity
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Open Entity (OEIS-1) client: JCS, did:key, eddsa-jcs-2022, Merkle proofs, adopt().
|
|
5
5
|
Project-URL: Homepage, https://openentity.org
|
|
6
6
|
Project-URL: Documentation, https://oeis.dev
|
|
@@ -43,4 +43,6 @@ Library, each taking the 32-byte seed and a registry URL: `adopt(name, registry=
|
|
|
43
43
|
|
|
44
44
|
Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, goose, langchain, ui-tars, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
|
|
45
45
|
|
|
46
|
+
HTTP message signatures (RFC 9421, Web Bot Auth profile): `sign_request(method, url, body, private_key, did)` returns `Signature-Input`, `Signature` and, for a non-empty body, `Content-Digest` (`private_key` is an `Identity`, a `KeyPair` or a 32-byte seed; ed25519, `keyid` = the signer's did:key, covers `@method`, `@target-uri`, `content-digest`; `created`, `expires`, `nonce`). `verify_request(method, url, headers, body)` returns `{"did", "keyid", "created", "expires", "nonce"}` or raises `HttpSigError` with a `.code` (`missing_headers`, `unknown_alg`, `bad_keyid`, `digest_mismatch`, `created_in_future`, `created_too_old`, `expired`, `lifetime_too_long`, `missing_nonce`, `bad_signature`). Nonce replay tracking is yours. Shared vectors: `test-vectors/http-signature.json`.
|
|
47
|
+
|
|
46
48
|
Written from the spec text alone, independent of the TypeScript SDK.
|
|
@@ -30,4 +30,6 @@ Library, each taking the 32-byte seed and a registry URL: `adopt(name, registry=
|
|
|
30
30
|
|
|
31
31
|
Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, goose, langchain, ui-tars, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
|
|
32
32
|
|
|
33
|
+
HTTP message signatures (RFC 9421, Web Bot Auth profile): `sign_request(method, url, body, private_key, did)` returns `Signature-Input`, `Signature` and, for a non-empty body, `Content-Digest` (`private_key` is an `Identity`, a `KeyPair` or a 32-byte seed; ed25519, `keyid` = the signer's did:key, covers `@method`, `@target-uri`, `content-digest`; `created`, `expires`, `nonce`). `verify_request(method, url, headers, body)` returns `{"did", "keyid", "created", "expires", "nonce"}` or raises `HttpSigError` with a `.code` (`missing_headers`, `unknown_alg`, `bad_keyid`, `digest_mismatch`, `created_in_future`, `created_too_old`, `expired`, `lifetime_too_long`, `missing_nonce`, `bad_signature`). Nonce replay tracking is yours. Shared vectors: `test-vectors/http-signature.json`.
|
|
34
|
+
|
|
33
35
|
Written from the spec text alone, independent of the TypeScript SDK.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from .client import adopt, dispute, find, present, record, rotate, withdraw # noqa: F401
|
|
2
2
|
from .core import * # noqa: F401,F403
|
|
3
3
|
from . import issuer # noqa: F401
|
|
4
|
+
from .httpsig import HttpSigError, sign_request, verify_request # noqa: F401
|
|
4
5
|
from .identity import Identity # noqa: F401
|
|
5
6
|
from .report import TrustReport, trust # noqa: F401
|
|
6
7
|
|
|
7
|
-
__version__ = "0.
|
|
8
|
+
__version__ = "0.5.0"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""RFC 9421 HTTP message signatures, Open Entity profile: ed25519, keyid = the signer's did:key,
|
|
2
|
+
covered ("@method" "@target-uri" "content-digest"), Content-Digest per RFC 9530, label sig1."""
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
|
|
10
|
+
from cryptography.exceptions import InvalidSignature
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
12
|
+
|
|
13
|
+
from . import core
|
|
14
|
+
|
|
15
|
+
SKEW = 5 # seconds of clock skew allowed on `created`
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpSigError(Exception):
|
|
19
|
+
def __init__(self, code: str):
|
|
20
|
+
super().__init__(code)
|
|
21
|
+
self.code = code
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _b64url(b: bytes) -> str:
|
|
25
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _digest(body: bytes) -> str:
|
|
29
|
+
return f"sha-256=:{base64.b64encode(hashlib.sha256(body).digest()).decode()}:"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _base(method: str, url: str, components: list[str], digest: str | None, params: str) -> bytes:
|
|
33
|
+
lines = []
|
|
34
|
+
for c in components:
|
|
35
|
+
v = {"@method": method.upper(), "@target-uri": url, "content-digest": digest}[c]
|
|
36
|
+
lines.append(f'"{c}": {v}')
|
|
37
|
+
lines.append(f'"@signature-params": {params}')
|
|
38
|
+
return "\n".join(lines).encode()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _seed(key) -> bytes:
|
|
42
|
+
for attr in ("seed", "kp"): # Identity -> KeyPair -> seed
|
|
43
|
+
if hasattr(key, attr):
|
|
44
|
+
return _seed(getattr(key, attr))
|
|
45
|
+
if isinstance(key, (bytes, bytearray)) and len(key) == 32:
|
|
46
|
+
return bytes(key)
|
|
47
|
+
raise ValueError("private_key must be an Identity, a KeyPair or a 32-byte Ed25519 seed")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def sign_request(method: str, url: str, body: bytes, private_key, did: str, *, created: int | None = None,
|
|
51
|
+
expires_in: int = 60, nonce: str | None = None) -> dict[str, str]:
|
|
52
|
+
created = int(time.time()) if created is None else created
|
|
53
|
+
nonce = nonce or _b64url(os.urandom(16))
|
|
54
|
+
out = {}
|
|
55
|
+
comps = ["@method", "@target-uri"]
|
|
56
|
+
if body:
|
|
57
|
+
out["Content-Digest"] = _digest(body)
|
|
58
|
+
comps.append("content-digest")
|
|
59
|
+
params = f'({" ".join(chr(34) + c + chr(34) for c in comps)});created={created};expires={created + expires_in};nonce="{nonce}";keyid="{did}";alg="ed25519"'
|
|
60
|
+
sig = Ed25519PrivateKey.from_private_bytes(_seed(private_key)).sign(_base(method, url, comps, out.get("Content-Digest"), params))
|
|
61
|
+
out["Signature-Input"] = f"sig1={params}"
|
|
62
|
+
out["Signature"] = f"sig1=:{base64.b64encode(sig).decode()}:"
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def verify_request(method: str, url: str, headers: Mapping[str, str], body: bytes, *, now: int | None = None,
|
|
67
|
+
max_age: int = 300) -> dict:
|
|
68
|
+
h = {k.lower(): v for k, v in headers.items()}
|
|
69
|
+
if "signature-input" not in h or "signature" not in h:
|
|
70
|
+
raise HttpSigError("missing_headers")
|
|
71
|
+
m = re.search(r"(?:^|,)\s*sig1=(\(([^)]*)\)((?:;[a-z]+=(?:\d+|\"[^\"]*\"))*))", h["signature-input"])
|
|
72
|
+
s = re.search(r"(?:^|,)\s*sig1=:([A-Za-z0-9+/]+={0,2}):", h["signature"])
|
|
73
|
+
if not m or not s:
|
|
74
|
+
raise HttpSigError("missing_headers")
|
|
75
|
+
params, comps = m.group(1), re.findall(r'"([^"]*)"', m.group(2))
|
|
76
|
+
p = {k: (v[1:-1] if v.startswith('"') else int(v)) for k, v in re.findall(r";([a-z]+)=(\d+|\"[^\"]*\")", m.group(3))}
|
|
77
|
+
if p.get("alg") != "ed25519":
|
|
78
|
+
raise HttpSigError("unknown_alg")
|
|
79
|
+
keyid = p.get("keyid", "")
|
|
80
|
+
try:
|
|
81
|
+
public = core.public_from_did_key(keyid)
|
|
82
|
+
if core.did_key(public) != keyid:
|
|
83
|
+
raise ValueError
|
|
84
|
+
except Exception:
|
|
85
|
+
raise HttpSigError("bad_keyid") from None
|
|
86
|
+
if not (isinstance(p.get("created"), int) and isinstance(p.get("expires"), int)):
|
|
87
|
+
raise HttpSigError("missing_headers")
|
|
88
|
+
if len(p.get("nonce", "")) < 22: # 16 bytes in base64url
|
|
89
|
+
raise HttpSigError("missing_nonce")
|
|
90
|
+
if comps[:2] != ["@method", "@target-uri"] or comps[2:] not in ([], ["content-digest"]):
|
|
91
|
+
raise HttpSigError("missing_headers")
|
|
92
|
+
digest = None
|
|
93
|
+
if body or "content-digest" in comps:
|
|
94
|
+
digest = h.get("content-digest")
|
|
95
|
+
if "content-digest" not in comps or digest != _digest(body):
|
|
96
|
+
raise HttpSigError("digest_mismatch")
|
|
97
|
+
now = int(time.time()) if now is None else now
|
|
98
|
+
if p["created"] > now + SKEW:
|
|
99
|
+
raise HttpSigError("created_in_future")
|
|
100
|
+
if now - p["created"] > max_age:
|
|
101
|
+
raise HttpSigError("created_too_old")
|
|
102
|
+
if p["expires"] < now:
|
|
103
|
+
raise HttpSigError("expired")
|
|
104
|
+
if p["expires"] - p["created"] > max_age:
|
|
105
|
+
raise HttpSigError("lifetime_too_long")
|
|
106
|
+
try:
|
|
107
|
+
Ed25519PublicKey.from_public_bytes(public).verify(base64.b64decode(s.group(1)), _base(method, url, comps, digest, params))
|
|
108
|
+
except (InvalidSignature, ValueError):
|
|
109
|
+
raise HttpSigError("bad_signature") from None
|
|
110
|
+
return {"did": keyid, "keyid": keyid, "created": p["created"], "expires": p["expires"], "nonce": p["nonce"]}
|
|
@@ -186,7 +186,7 @@ def test_adopt_and_cli(tmp_path, monkeypatch, capsys):
|
|
|
186
186
|
def test_cli_version(capsys):
|
|
187
187
|
with pytest.raises(SystemExit) as e:
|
|
188
188
|
cli.main(["--version"])
|
|
189
|
-
assert e.value.code == 0 and "0.
|
|
189
|
+
assert e.value.code == 0 and "0.5.0" in capsys.readouterr().out
|
|
190
190
|
|
|
191
191
|
|
|
192
192
|
# ---- shared vectors, checked with the independent verifier's own logic ----
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
7
|
+
|
|
8
|
+
from openentity import HttpSigError, Identity, core, sign_request, verify_request
|
|
9
|
+
from openentity.httpsig import _base, _digest
|
|
10
|
+
|
|
11
|
+
VEC = json.loads((Path(__file__).resolve().parents[3] / "test-vectors/http-signature.json").read_text())
|
|
12
|
+
SEED = bytes(range(1, 33)) # published throwaway seed, test-vectors/keys.json
|
|
13
|
+
KP = core.KeyPair(SEED)
|
|
14
|
+
URL, NOW, BODY = "https://gw.example.com/v1/x", 1_800_000_000, b'{"a":1}'
|
|
15
|
+
NONCE = "AAECAwQFBgcICQoLDA0ODw"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def req(**kw):
|
|
19
|
+
return sign_request("POST", URL, BODY, SEED, KP.did, created=NOW, nonce=NONCE, **kw)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check(h, code, **kw):
|
|
23
|
+
with pytest.raises(HttpSigError) as e:
|
|
24
|
+
verify_request(kw.pop("method", "POST"), kw.pop("url", URL), h, kw.pop("body", BODY), now=kw.pop("now", NOW), **kw)
|
|
25
|
+
assert e.value.code == code
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def forge(params, comps=("@method", "@target-uri", "content-digest"), body=BODY, key=SEED):
|
|
29
|
+
"""A correctly signed request with arbitrary Signature-Input parameters."""
|
|
30
|
+
h = {"Content-Digest": _digest(body)} if body else {}
|
|
31
|
+
sig = Ed25519PrivateKey.from_private_bytes(key).sign(_base("POST", URL, list(comps), h.get("Content-Digest"), params))
|
|
32
|
+
return {**h, "Signature-Input": f"sig1={params}", "Signature": f"sig1=:{base64.b64encode(sig).decode()}:"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def params(created=NOW, expires=NOW + 60, nonce=NONCE, keyid=KP.did, alg="ed25519", comps='"@method" "@target-uri" "content-digest"'):
|
|
36
|
+
n = f';nonce="{nonce}"' if nonce else ""
|
|
37
|
+
return f'({comps});created={created};expires={expires}{n};keyid="{keyid}";alg="{alg}"'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("case", VEC["valid"], ids=lambda c: c["name"])
|
|
41
|
+
def test_vectors_sign_and_verify(case):
|
|
42
|
+
h = sign_request(case["method"], case["url"], case["body"].encode(), SEED, VEC["did"], created=case["created"],
|
|
43
|
+
expires_in=case["expires_in"], nonce=case["nonce"])
|
|
44
|
+
assert h == case["headers"] # byte-identical to the TS SDK
|
|
45
|
+
out = verify_request(case["method"], case["url"], case["headers"], case["body"].encode(), now=VEC["now"])
|
|
46
|
+
assert out == {"did": VEC["did"], "keyid": VEC["did"], "created": case["created"], "expires": case["created"] + 60, "nonce": case["nonce"]}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("case", VEC["invalid"], ids=lambda c: c["name"])
|
|
50
|
+
def test_vectors_reject(case):
|
|
51
|
+
check(case["headers"], case["code"], method=case["method"], url=case["url"], body=case["body"].encode(), now=case.get("now", VEC["now"]))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_roundtrip_and_key_types():
|
|
55
|
+
assert verify_request("post", URL, req(), BODY, now=NOW)["did"] == KP.did
|
|
56
|
+
for key in (KP, Identity(SEED, "test")):
|
|
57
|
+
assert sign_request("POST", URL, BODY, key, KP.did, created=NOW, nonce=NONCE) == req()
|
|
58
|
+
h = sign_request("GET", URL, b"", SEED, KP.did)
|
|
59
|
+
assert "Content-Digest" not in h and "content-digest" not in h["Signature-Input"]
|
|
60
|
+
assert verify_request("GET", URL, h, b"")["did"] == KP.did
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_header_lookup_is_case_insensitive():
|
|
64
|
+
h = {k.lower(): v for k, v in req().items()}
|
|
65
|
+
assert verify_request("POST", URL, h, BODY, now=NOW)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_missing_headers():
|
|
69
|
+
check({}, "missing_headers")
|
|
70
|
+
check({k: v for k, v in req().items() if k != "Signature"}, "missing_headers")
|
|
71
|
+
check({**req(), "Signature": "garbage"}, "missing_headers")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_unknown_alg():
|
|
75
|
+
check(forge(params(alg="rsa-pss-sha512")), "unknown_alg")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_bad_keyid():
|
|
79
|
+
check(forge(params(keyid="did:web:example.com")), "bad_keyid")
|
|
80
|
+
check(forge(params(keyid="did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme")), "bad_keyid") # secp256k1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_digest_mismatch():
|
|
84
|
+
check(req(), "digest_mismatch", body=b"other")
|
|
85
|
+
check({k: v for k, v in req().items() if k != "Content-Digest"}, "digest_mismatch")
|
|
86
|
+
check(forge(params(comps='"@method" "@target-uri"')), "digest_mismatch") # body present, digest not covered
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_created_in_future_and_skew():
|
|
90
|
+
check(req(), "created_in_future", now=NOW - 6)
|
|
91
|
+
assert verify_request("POST", URL, req(), BODY, now=NOW - 5)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_created_too_old():
|
|
95
|
+
check(forge(params(created=NOW - 400, expires=NOW + 10)), "created_too_old")
|
|
96
|
+
check(req(), "created_too_old", now=NOW + 301, max_age=300)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_expired():
|
|
100
|
+
check(req(), "expired", now=NOW + 61)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_lifetime_too_long():
|
|
104
|
+
check(forge(params(expires=NOW + 301)), "lifetime_too_long")
|
|
105
|
+
check(sign_request("POST", URL, BODY, SEED, KP.did, created=NOW, nonce=NONCE, expires_in=200), "lifetime_too_long", max_age=100)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_missing_nonce():
|
|
109
|
+
check(forge(params(nonce=None)), "missing_nonce")
|
|
110
|
+
check(forge(params(nonce="short")), "missing_nonce")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_bad_signature():
|
|
114
|
+
check(req(), "bad_signature", url=URL + "?y=1")
|
|
115
|
+
check(req(), "bad_signature", method="GET")
|
|
116
|
+
check(forge(params(), key=bytes(32)), "bad_signature") # signed by another key
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|