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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openentity
3
- Version: 0.4.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.4.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"]}
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openentity"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Open Entity (OEIS-1) client: JCS, did:key, eddsa-jcs-2022, Merkle proofs, adopt()."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.4.0" in capsys.readouterr().out
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
@@ -189,7 +189,7 @@ wheels = [
189
189
 
190
190
  [[package]]
191
191
  name = "openentity"
192
- version = "0.4.0"
192
+ version = "0.5.0"
193
193
  source = { editable = "." }
194
194
  dependencies = [
195
195
  { name = "cryptography" },
File without changes
File without changes