rare-identity-protocol 0.1.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.
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: rare-identity-protocol
3
+ Version: 0.1.0
4
+ Summary: Rare identity protocol primitives and signing helpers
5
+ Project-URL: Homepage, https://api.rareid.cc
6
+ Project-URL: Repository, https://github.com/0xsidfan/Rare
7
+ Project-URL: Documentation, https://github.com/0xsidfan/Rare/tree/main/rare-identity-protocol-python
8
+ Project-URL: Issues, https://github.com/0xsidfan/Rare/issues
9
+ Keywords: rare,identity,protocol,ed25519,jws
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: cryptography>=42.0.0
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=8.2.0; extra == "test"
15
+
16
+ # rare-identity-protocol-python
17
+
18
+ Rare 的协议层 Python 包,提供:
19
+
20
+ - 固定 signing input 构造
21
+ - Ed25519 / JWS 签名与验签辅助
22
+ - name normalization / validation
23
+ - expiring map / set 等基础安全数据结构
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install rare-identity-protocol
29
+ ```
30
+
31
+ 本地工作区开发:
32
+
33
+ ```bash
34
+ pip install -e .[test]
35
+ ```
@@ -0,0 +1,20 @@
1
+ # rare-identity-protocol-python
2
+
3
+ Rare 的协议层 Python 包,提供:
4
+
5
+ - 固定 signing input 构造
6
+ - Ed25519 / JWS 签名与验签辅助
7
+ - name normalization / validation
8
+ - expiring map / set 等基础安全数据结构
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install rare-identity-protocol
14
+ ```
15
+
16
+ 本地工作区开发:
17
+
18
+ ```bash
19
+ pip install -e .[test]
20
+ ```
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rare-identity-protocol"
7
+ version = "0.1.0"
8
+ description = "Rare identity protocol primitives and signing helpers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "cryptography>=42.0.0",
13
+ ]
14
+ keywords = ["rare", "identity", "protocol", "ed25519", "jws"]
15
+
16
+ [project.optional-dependencies]
17
+ test = [
18
+ "pytest>=8.2.0",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://api.rareid.cc"
23
+ Repository = "https://github.com/0xsidfan/Rare"
24
+ Documentation = "https://github.com/0xsidfan/Rare/tree/main/rare-identity-protocol-python"
25
+ Issues = "https://github.com/0xsidfan/Rare/issues"
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+ include = ["rare_identity_protocol*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,75 @@
1
+ from rare_identity_protocol.actions import build_action_payload
2
+ from rare_identity_protocol.challenge import (
3
+ build_auth_challenge_payload,
4
+ build_full_attestation_issue_payload,
5
+ build_agent_auth_payload,
6
+ build_register_payload,
7
+ build_set_name_payload,
8
+ build_upgrade_request_payload,
9
+ )
10
+ from rare_identity_protocol.crypto import (
11
+ b64url_decode,
12
+ b64url_encode,
13
+ decode_jws,
14
+ generate_ed25519_keypair,
15
+ generate_nonce,
16
+ json_dumps_compact,
17
+ load_private_key,
18
+ load_public_key,
19
+ now_ts,
20
+ public_key_to_b64,
21
+ sign_detached,
22
+ sign_jws,
23
+ verify_detached,
24
+ verify_jws,
25
+ )
26
+ from rare_identity_protocol.errors import (
27
+ ProtocolError,
28
+ ResourceLimitError,
29
+ SignatureError,
30
+ TokenValidationError,
31
+ )
32
+ from rare_identity_protocol.expiring_store import ExpiringMap, ExpiringSet
33
+ from rare_identity_protocol.name_policy import normalize_name, validate_name
34
+ from rare_identity_protocol.tokens import (
35
+ issue_agent_delegation,
36
+ issue_full_identity_attestation,
37
+ issue_public_identity_attestation,
38
+ issue_rare_delegation,
39
+ )
40
+
41
+ __all__ = [
42
+ "ProtocolError",
43
+ "ResourceLimitError",
44
+ "SignatureError",
45
+ "TokenValidationError",
46
+ "ExpiringMap",
47
+ "ExpiringSet",
48
+ "b64url_decode",
49
+ "b64url_encode",
50
+ "decode_jws",
51
+ "generate_ed25519_keypair",
52
+ "generate_nonce",
53
+ "json_dumps_compact",
54
+ "load_private_key",
55
+ "load_public_key",
56
+ "now_ts",
57
+ "public_key_to_b64",
58
+ "sign_detached",
59
+ "sign_jws",
60
+ "verify_detached",
61
+ "verify_jws",
62
+ "build_auth_challenge_payload",
63
+ "build_full_attestation_issue_payload",
64
+ "build_agent_auth_payload",
65
+ "build_register_payload",
66
+ "build_action_payload",
67
+ "build_set_name_payload",
68
+ "build_upgrade_request_payload",
69
+ "normalize_name",
70
+ "validate_name",
71
+ "issue_agent_delegation",
72
+ "issue_public_identity_attestation",
73
+ "issue_full_identity_attestation",
74
+ "issue_rare_delegation",
75
+ ]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ def _canonical_json(payload: dict[str, Any]) -> str:
9
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
10
+
11
+
12
+ def build_action_payload(
13
+ *,
14
+ aud: str,
15
+ session_token: str,
16
+ action: str,
17
+ action_payload: dict[str, Any],
18
+ nonce: str,
19
+ issued_at: int,
20
+ expires_at: int,
21
+ ) -> str:
22
+ body_hash = hashlib.sha256(_canonical_json(action_payload).encode("utf-8")).hexdigest()
23
+ return (
24
+ f"rare-act-v1:{aud}:{session_token}:{action}:{body_hash}:{nonce}:{issued_at}:{expires_at}"
25
+ )
@@ -0,0 +1,76 @@
1
+ from rare_identity_protocol.name_policy import normalize_name
2
+
3
+
4
+ def build_auth_challenge_payload(
5
+ *,
6
+ aud: str,
7
+ nonce: str,
8
+ issued_at: int,
9
+ expires_at: int,
10
+ ) -> str:
11
+ return f"rare-auth-v1:{aud}:{nonce}:{issued_at}:{expires_at}"
12
+
13
+
14
+ def build_set_name_payload(
15
+ *,
16
+ agent_id: str,
17
+ name: str,
18
+ nonce: str,
19
+ issued_at: int,
20
+ expires_at: int,
21
+ ) -> str:
22
+ normalized_name = normalize_name(name)
23
+ return f"rare-name-v1:{agent_id}:{normalized_name}:{nonce}:{issued_at}:{expires_at}"
24
+
25
+
26
+ def build_register_payload(
27
+ *,
28
+ agent_id: str,
29
+ name: str,
30
+ nonce: str,
31
+ issued_at: int,
32
+ expires_at: int,
33
+ ) -> str:
34
+ normalized_name = normalize_name(name)
35
+ return f"rare-register-v1:{agent_id}:{normalized_name}:{nonce}:{issued_at}:{expires_at}"
36
+
37
+
38
+ def build_full_attestation_issue_payload(
39
+ *,
40
+ agent_id: str,
41
+ platform_aud: str,
42
+ nonce: str,
43
+ issued_at: int,
44
+ expires_at: int,
45
+ ) -> str:
46
+ return f"rare-full-att-v1:{agent_id}:{platform_aud}:{nonce}:{issued_at}:{expires_at}"
47
+
48
+
49
+ def build_upgrade_request_payload(
50
+ *,
51
+ agent_id: str,
52
+ target_level: str,
53
+ request_id: str,
54
+ nonce: str,
55
+ issued_at: int,
56
+ expires_at: int,
57
+ ) -> str:
58
+ return (
59
+ f"rare-upgrade-v1:{agent_id}:{target_level}:"
60
+ f"{request_id}:{nonce}:{issued_at}:{expires_at}"
61
+ )
62
+
63
+
64
+ def build_agent_auth_payload(
65
+ *,
66
+ agent_id: str,
67
+ operation: str,
68
+ resource_id: str,
69
+ nonce: str,
70
+ issued_at: int,
71
+ expires_at: int,
72
+ ) -> str:
73
+ return (
74
+ f"rare-agent-auth-v1:{agent_id}:{operation}:{resource_id}:"
75
+ f"{nonce}:{issued_at}:{expires_at}"
76
+ )
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import secrets
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from cryptography.exceptions import InvalidSignature
11
+ from cryptography.hazmat.primitives import serialization
12
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
13
+ Ed25519PrivateKey,
14
+ Ed25519PublicKey,
15
+ )
16
+
17
+ from rare_identity_protocol.errors import SignatureError, TokenValidationError
18
+
19
+
20
+ RAW_KEY_SIZE = 32
21
+
22
+
23
+ def now_ts() -> int:
24
+ return int(time.time())
25
+
26
+
27
+ def b64url_encode(data: bytes) -> str:
28
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
29
+
30
+
31
+ def b64url_decode(data: str) -> bytes:
32
+ padding = "=" * ((4 - len(data) % 4) % 4)
33
+ return base64.urlsafe_b64decode((data + padding).encode("ascii"))
34
+
35
+
36
+ def json_dumps_compact(payload: dict[str, Any]) -> bytes:
37
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
38
+
39
+
40
+ def generate_ed25519_keypair() -> tuple[str, str]:
41
+ private_key = Ed25519PrivateKey.generate()
42
+ public_key = private_key.public_key()
43
+ private_raw = private_key.private_bytes(
44
+ encoding=serialization.Encoding.Raw,
45
+ format=serialization.PrivateFormat.Raw,
46
+ encryption_algorithm=serialization.NoEncryption(),
47
+ )
48
+ public_raw = public_key.public_bytes(
49
+ encoding=serialization.Encoding.Raw,
50
+ format=serialization.PublicFormat.Raw,
51
+ )
52
+ return b64url_encode(private_raw), b64url_encode(public_raw)
53
+
54
+
55
+ def load_private_key(raw_b64url: str) -> Ed25519PrivateKey:
56
+ key_bytes = b64url_decode(raw_b64url)
57
+ if len(key_bytes) != RAW_KEY_SIZE:
58
+ raise TokenValidationError("invalid Ed25519 private key length")
59
+ return Ed25519PrivateKey.from_private_bytes(key_bytes)
60
+
61
+
62
+ def load_public_key(raw_b64url: str) -> Ed25519PublicKey:
63
+ key_bytes = b64url_decode(raw_b64url)
64
+ if len(key_bytes) != RAW_KEY_SIZE:
65
+ raise TokenValidationError("invalid Ed25519 public key length")
66
+ return Ed25519PublicKey.from_public_bytes(key_bytes)
67
+
68
+
69
+ def public_key_to_b64(public_key: Ed25519PublicKey) -> str:
70
+ return b64url_encode(
71
+ public_key.public_bytes(
72
+ encoding=serialization.Encoding.Raw,
73
+ format=serialization.PublicFormat.Raw,
74
+ )
75
+ )
76
+
77
+
78
+ def sign_detached(message: str, private_key: Ed25519PrivateKey) -> str:
79
+ signature = private_key.sign(message.encode("utf-8"))
80
+ return b64url_encode(signature)
81
+
82
+
83
+ def verify_detached(message: str, signature_b64url: str, public_key: Ed25519PublicKey) -> None:
84
+ try:
85
+ signature = b64url_decode(signature_b64url)
86
+ public_key.verify(signature, message.encode("utf-8"))
87
+ except (InvalidSignature, ValueError) as exc:
88
+ raise SignatureError("invalid detached signature") from exc
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class DecodedJWS:
93
+ header: dict[str, Any]
94
+ payload: dict[str, Any]
95
+ signing_input: bytes
96
+ signature: bytes
97
+
98
+
99
+ def sign_jws(
100
+ *,
101
+ payload: dict[str, Any],
102
+ private_key: Ed25519PrivateKey,
103
+ kid: str,
104
+ typ: str,
105
+ ) -> str:
106
+ header = {"alg": "EdDSA", "kid": kid, "typ": typ}
107
+ encoded_header = b64url_encode(json_dumps_compact(header))
108
+ encoded_payload = b64url_encode(json_dumps_compact(payload))
109
+ signing_input = f"{encoded_header}.{encoded_payload}".encode("ascii")
110
+ signature = private_key.sign(signing_input)
111
+ encoded_signature = b64url_encode(signature)
112
+ return f"{encoded_header}.{encoded_payload}.{encoded_signature}"
113
+
114
+
115
+ def decode_jws(token: str) -> DecodedJWS:
116
+ try:
117
+ encoded_header, encoded_payload, encoded_signature = token.split(".")
118
+ except ValueError as exc:
119
+ raise TokenValidationError("invalid compact JWS format") from exc
120
+
121
+ try:
122
+ header = json.loads(b64url_decode(encoded_header))
123
+ payload = json.loads(b64url_decode(encoded_payload))
124
+ signature = b64url_decode(encoded_signature)
125
+ except (ValueError, json.JSONDecodeError) as exc:
126
+ raise TokenValidationError("invalid JWS encoding") from exc
127
+
128
+ if not isinstance(header, dict) or not isinstance(payload, dict):
129
+ raise TokenValidationError("JWS header/payload must be JSON objects")
130
+
131
+ signing_input = f"{encoded_header}.{encoded_payload}".encode("ascii")
132
+ return DecodedJWS(
133
+ header=header,
134
+ payload=payload,
135
+ signing_input=signing_input,
136
+ signature=signature,
137
+ )
138
+
139
+
140
+ def verify_jws(token: str, public_key: Ed25519PublicKey) -> DecodedJWS:
141
+ decoded = decode_jws(token)
142
+
143
+ if decoded.header.get("alg") != "EdDSA":
144
+ raise TokenValidationError("unsupported JWS alg")
145
+
146
+ try:
147
+ public_key.verify(decoded.signature, decoded.signing_input)
148
+ except InvalidSignature as exc:
149
+ raise SignatureError("invalid JWS signature") from exc
150
+
151
+ return decoded
152
+
153
+
154
+ def generate_nonce(length: int = 24) -> str:
155
+ return secrets.token_urlsafe(length)
@@ -0,0 +1,14 @@
1
+ class ProtocolError(ValueError):
2
+ """Base protocol error for validation/signature failures."""
3
+
4
+
5
+ class SignatureError(ProtocolError):
6
+ """Raised when signature verification fails."""
7
+
8
+
9
+ class TokenValidationError(ProtocolError):
10
+ """Raised when a token does not match protocol constraints."""
11
+
12
+
13
+ class ResourceLimitError(ProtocolError):
14
+ """Raised when an in-memory security store reaches its capacity."""
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import heapq
4
+ from dataclasses import dataclass
5
+ from typing import Generic, Iterator, TypeVar
6
+
7
+ from rare_identity_protocol.errors import ResourceLimitError
8
+
9
+ K = TypeVar("K")
10
+ V = TypeVar("V")
11
+
12
+
13
+ @dataclass
14
+ class _Entry(Generic[V]):
15
+ value: V
16
+ expires_at: int
17
+ revision: int
18
+
19
+
20
+ class ExpiringMap(Generic[K, V]):
21
+ """Bounded expiring map with O(1) reads and O(log n) expiry operations."""
22
+
23
+ def __init__(self, *, capacity: int) -> None:
24
+ if capacity <= 0:
25
+ raise ValueError("capacity must be greater than 0")
26
+ self._capacity = capacity
27
+ self._entries: dict[K, _Entry[V]] = {}
28
+ self._expiry_heap: list[tuple[int, int, K]] = []
29
+ self._revision = 0
30
+
31
+ def cleanup(self, *, now: int, grace_seconds: int = 30) -> None:
32
+ cutoff = now - grace_seconds
33
+ while self._expiry_heap and self._expiry_heap[0][0] < cutoff:
34
+ expires_at, revision, key = heapq.heappop(self._expiry_heap)
35
+ current = self._entries.get(key)
36
+ if current is None:
37
+ continue
38
+ if current.revision != revision:
39
+ continue
40
+ if current.expires_at != expires_at:
41
+ continue
42
+ del self._entries[key]
43
+
44
+ def set(self, *, key: K, value: V, expires_at: int, now: int, grace_seconds: int = 30) -> None:
45
+ self.cleanup(now=now, grace_seconds=grace_seconds)
46
+ if key not in self._entries and len(self._entries) >= self._capacity:
47
+ raise ResourceLimitError("security replay/session store capacity exceeded")
48
+ self._revision += 1
49
+ revision = self._revision
50
+ self._entries[key] = _Entry(value=value, expires_at=expires_at, revision=revision)
51
+ heapq.heappush(self._expiry_heap, (expires_at, revision, key))
52
+
53
+ def get(self, key: K) -> V | None:
54
+ entry = self._entries.get(key)
55
+ return entry.value if entry is not None else None
56
+
57
+ def pop(self, key: K) -> V | None:
58
+ entry = self._entries.pop(key, None)
59
+ return entry.value if entry is not None else None
60
+
61
+ def discard(self, key: K) -> None:
62
+ self._entries.pop(key, None)
63
+
64
+ def __contains__(self, key: K) -> bool:
65
+ return key in self._entries
66
+
67
+ def __len__(self) -> int:
68
+ return len(self._entries)
69
+
70
+ def keys(self) -> Iterator[K]:
71
+ return iter(self._entries.keys())
72
+
73
+ def values(self) -> Iterator[V]:
74
+ for entry in self._entries.values():
75
+ yield entry.value
76
+
77
+ def items(self) -> Iterator[tuple[K, V]]:
78
+ for key, entry in self._entries.items():
79
+ yield key, entry.value
80
+
81
+
82
+ class ExpiringSet(Generic[K]):
83
+ """Bounded expiring set for replay protection keys."""
84
+
85
+ def __init__(self, *, capacity: int) -> None:
86
+ self._store: ExpiringMap[K, bool] = ExpiringMap(capacity=capacity)
87
+
88
+ def cleanup(self, *, now: int, grace_seconds: int = 30) -> None:
89
+ self._store.cleanup(now=now, grace_seconds=grace_seconds)
90
+
91
+ def add(self, *, key: K, expires_at: int, now: int, grace_seconds: int = 30) -> None:
92
+ self._store.set(
93
+ key=key,
94
+ value=True,
95
+ expires_at=expires_at,
96
+ now=now,
97
+ grace_seconds=grace_seconds,
98
+ )
99
+
100
+ def contains(self, key: K) -> bool:
101
+ return key in self._store
102
+
103
+ def discard(self, key: K) -> None:
104
+ self._store.discard(key)
105
+
106
+ def __len__(self) -> int:
107
+ return len(self._store)
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+
6
+ from rare_identity_protocol.errors import TokenValidationError
7
+
8
+
9
+ MAX_NAME_LENGTH = 48
10
+ MIN_NAME_LENGTH = 1
11
+ RESERVED_NAMES = {
12
+ "admin",
13
+ "root",
14
+ "support",
15
+ "official",
16
+ "rare",
17
+ }
18
+ _CONTROL_CHAR_RE = re.compile(r"[\x00-\x1F\x7F]")
19
+
20
+
21
+ def normalize_name(name: str) -> str:
22
+ return unicodedata.normalize("NFKC", name.strip())
23
+
24
+
25
+ def validate_name(name: str, reserved_words: set[str] | None = None) -> str:
26
+ normalized = normalize_name(name)
27
+ if len(normalized) < MIN_NAME_LENGTH or len(normalized) > MAX_NAME_LENGTH:
28
+ raise TokenValidationError("name length must be between 1 and 48")
29
+
30
+ if _CONTROL_CHAR_RE.search(normalized):
31
+ raise TokenValidationError("name must not include control characters")
32
+
33
+ for char in normalized:
34
+ if unicodedata.category(char).startswith("C"):
35
+ raise TokenValidationError("name must not include control characters")
36
+
37
+ deny_words = reserved_words or RESERVED_NAMES
38
+ if normalized.casefold() in {w.casefold() for w in deny_words}:
39
+ raise TokenValidationError("name is reserved")
40
+
41
+ return normalized
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable
4
+
5
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
6
+
7
+ from rare_identity_protocol.crypto import now_ts, sign_jws
8
+ from rare_identity_protocol.errors import TokenValidationError
9
+
10
+
11
+ def _build_identity_claims(
12
+ *,
13
+ name: str,
14
+ iat: int,
15
+ name_updated_at: int | None,
16
+ owner_id: str | None,
17
+ org_id: str | None,
18
+ twitter: dict[str, str] | None,
19
+ github: dict[str, str] | None,
20
+ linkedin: dict[str, str] | None,
21
+ include_extended_claims: bool,
22
+ ) -> dict:
23
+ claims: dict[str, object] = {
24
+ "profile": {
25
+ "name": name,
26
+ "name_updated_at": name_updated_at if name_updated_at is not None else iat,
27
+ }
28
+ }
29
+ if not include_extended_claims:
30
+ return claims
31
+
32
+ if org_id:
33
+ claims["org_id"] = org_id
34
+ if owner_id:
35
+ claims["owner_id"] = owner_id
36
+ if twitter:
37
+ claims["twitter"] = twitter
38
+ if github:
39
+ claims["github"] = github
40
+ if linkedin:
41
+ claims["linkedin"] = linkedin
42
+ return claims
43
+
44
+
45
+ def build_identity_payload(
46
+ *,
47
+ agent_id: str,
48
+ level: str,
49
+ name: str,
50
+ iat: int,
51
+ exp: int,
52
+ jti: str,
53
+ aud: str | None = None,
54
+ include_extended_claims: bool = True,
55
+ name_updated_at: int | None = None,
56
+ owner_id: str | None = None,
57
+ org_id: str | None = None,
58
+ twitter: dict[str, str] | None = None,
59
+ github: dict[str, str] | None = None,
60
+ linkedin: dict[str, str] | None = None,
61
+ ) -> dict:
62
+ claims = _build_identity_claims(
63
+ name=name,
64
+ iat=iat,
65
+ name_updated_at=name_updated_at,
66
+ owner_id=owner_id,
67
+ org_id=org_id,
68
+ twitter=twitter,
69
+ github=github,
70
+ linkedin=linkedin,
71
+ include_extended_claims=include_extended_claims,
72
+ )
73
+
74
+ payload = {
75
+ "typ": "rare.identity",
76
+ "ver": 1,
77
+ "iss": "rare",
78
+ "sub": agent_id,
79
+ "lvl": level,
80
+ "claims": claims,
81
+ "iat": iat,
82
+ "exp": exp,
83
+ "jti": jti,
84
+ }
85
+ if aud:
86
+ payload["aud"] = aud
87
+ return payload
88
+
89
+
90
+ def issue_public_identity_attestation(
91
+ *,
92
+ agent_id: str,
93
+ level: str,
94
+ name: str,
95
+ kid: str,
96
+ signer_private_key: Ed25519PrivateKey,
97
+ ttl_seconds: int,
98
+ jti: str,
99
+ name_updated_at: int | None = None,
100
+ owner_id: str | None = None,
101
+ org_id: str | None = None,
102
+ twitter: dict[str, str] | None = None,
103
+ github: dict[str, str] | None = None,
104
+ linkedin: dict[str, str] | None = None,
105
+ ) -> str:
106
+ iat = now_ts()
107
+ exp = iat + ttl_seconds
108
+ public_level = level if level in {"L0", "L1"} else "L1"
109
+ payload = build_identity_payload(
110
+ agent_id=agent_id,
111
+ level=public_level,
112
+ name=name,
113
+ iat=iat,
114
+ exp=exp,
115
+ jti=jti,
116
+ include_extended_claims=False,
117
+ name_updated_at=name_updated_at,
118
+ )
119
+ return sign_jws(
120
+ payload=payload,
121
+ private_key=signer_private_key,
122
+ kid=kid,
123
+ typ="rare.identity.public+jws",
124
+ )
125
+
126
+
127
+ def issue_full_identity_attestation(
128
+ *,
129
+ agent_id: str,
130
+ level: str,
131
+ name: str,
132
+ aud: str,
133
+ kid: str,
134
+ signer_private_key: Ed25519PrivateKey,
135
+ ttl_seconds: int,
136
+ jti: str,
137
+ name_updated_at: int | None = None,
138
+ owner_id: str | None = None,
139
+ org_id: str | None = None,
140
+ twitter: dict[str, str] | None = None,
141
+ github: dict[str, str] | None = None,
142
+ linkedin: dict[str, str] | None = None,
143
+ ) -> str:
144
+ iat = now_ts()
145
+ exp = iat + ttl_seconds
146
+ payload = build_identity_payload(
147
+ agent_id=agent_id,
148
+ level=level,
149
+ name=name,
150
+ aud=aud,
151
+ iat=iat,
152
+ exp=exp,
153
+ jti=jti,
154
+ include_extended_claims=True,
155
+ name_updated_at=name_updated_at,
156
+ owner_id=owner_id,
157
+ org_id=org_id,
158
+ twitter=twitter,
159
+ github=github,
160
+ linkedin=linkedin,
161
+ )
162
+ return sign_jws(
163
+ payload=payload,
164
+ private_key=signer_private_key,
165
+ kid=kid,
166
+ typ="rare.identity.full+jws",
167
+ )
168
+
169
+
170
+ def issue_agent_delegation(
171
+ *,
172
+ agent_id: str,
173
+ session_pubkey: str,
174
+ aud: str,
175
+ scope: Iterable[str],
176
+ signer_private_key: Ed25519PrivateKey,
177
+ kid: str,
178
+ ttl_seconds: int = 3600,
179
+ jti: str,
180
+ ) -> str:
181
+ if not isinstance(jti, str) or not jti.strip():
182
+ raise TokenValidationError("delegation jti is required")
183
+ iat = now_ts()
184
+ payload = {
185
+ "typ": "rare.delegation",
186
+ "ver": 1,
187
+ "iss": "agent",
188
+ "agent_id": agent_id,
189
+ "session_pubkey": session_pubkey,
190
+ "aud": aud,
191
+ "scope": list(scope),
192
+ "iat": iat,
193
+ "exp": iat + ttl_seconds,
194
+ "act": "delegated_by_agent",
195
+ }
196
+ payload["jti"] = jti
197
+
198
+ return sign_jws(
199
+ payload=payload,
200
+ private_key=signer_private_key,
201
+ kid=kid,
202
+ typ="rare.delegation+jws",
203
+ )
204
+
205
+
206
+ def issue_rare_delegation(
207
+ *,
208
+ agent_id: str,
209
+ session_pubkey: str,
210
+ aud: str,
211
+ scope: Iterable[str],
212
+ signer_private_key: Ed25519PrivateKey,
213
+ kid: str,
214
+ ttl_seconds: int = 3600,
215
+ jti: str,
216
+ ) -> str:
217
+ if not isinstance(jti, str) or not jti.strip():
218
+ raise TokenValidationError("delegation jti is required")
219
+ iat = now_ts()
220
+ payload = {
221
+ "typ": "rare.delegation",
222
+ "ver": 1,
223
+ "iss": "rare-signer",
224
+ "agent_id": agent_id,
225
+ "session_pubkey": session_pubkey,
226
+ "aud": aud,
227
+ "scope": list(scope),
228
+ "iat": iat,
229
+ "exp": iat + ttl_seconds,
230
+ "act": "delegated_by_rare",
231
+ }
232
+ payload["jti"] = jti
233
+
234
+ return sign_jws(
235
+ payload=payload,
236
+ private_key=signer_private_key,
237
+ kid=kid,
238
+ typ="rare.delegation+jws",
239
+ )
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: rare-identity-protocol
3
+ Version: 0.1.0
4
+ Summary: Rare identity protocol primitives and signing helpers
5
+ Project-URL: Homepage, https://api.rareid.cc
6
+ Project-URL: Repository, https://github.com/0xsidfan/Rare
7
+ Project-URL: Documentation, https://github.com/0xsidfan/Rare/tree/main/rare-identity-protocol-python
8
+ Project-URL: Issues, https://github.com/0xsidfan/Rare/issues
9
+ Keywords: rare,identity,protocol,ed25519,jws
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: cryptography>=42.0.0
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=8.2.0; extra == "test"
15
+
16
+ # rare-identity-protocol-python
17
+
18
+ Rare 的协议层 Python 包,提供:
19
+
20
+ - 固定 signing input 构造
21
+ - Ed25519 / JWS 签名与验签辅助
22
+ - name normalization / validation
23
+ - expiring map / set 等基础安全数据结构
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install rare-identity-protocol
29
+ ```
30
+
31
+ 本地工作区开发:
32
+
33
+ ```bash
34
+ pip install -e .[test]
35
+ ```
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/rare_identity_protocol/__init__.py
4
+ src/rare_identity_protocol/actions.py
5
+ src/rare_identity_protocol/challenge.py
6
+ src/rare_identity_protocol/crypto.py
7
+ src/rare_identity_protocol/errors.py
8
+ src/rare_identity_protocol/expiring_store.py
9
+ src/rare_identity_protocol/name_policy.py
10
+ src/rare_identity_protocol/tokens.py
11
+ src/rare_identity_protocol.egg-info/PKG-INFO
12
+ src/rare_identity_protocol.egg-info/SOURCES.txt
13
+ src/rare_identity_protocol.egg-info/dependency_links.txt
14
+ src/rare_identity_protocol.egg-info/requires.txt
15
+ src/rare_identity_protocol.egg-info/top_level.txt
16
+ tests/test_protocol_unit.py
17
+ tests/test_vectors.py
@@ -0,0 +1,4 @@
1
+ cryptography>=42.0.0
2
+
3
+ [test]
4
+ pytest>=8.2.0
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from rare_identity_protocol import (
6
+ ExpiringMap,
7
+ ExpiringSet,
8
+ ResourceLimitError,
9
+ SignatureError,
10
+ TokenValidationError,
11
+ b64url_decode,
12
+ b64url_encode,
13
+ decode_jws,
14
+ generate_ed25519_keypair,
15
+ load_private_key,
16
+ load_public_key,
17
+ sign_detached,
18
+ sign_jws,
19
+ validate_name,
20
+ verify_detached,
21
+ verify_jws,
22
+ )
23
+
24
+
25
+ def test_load_key_rejects_invalid_length() -> None:
26
+ invalid_b64 = b64url_encode(b"short")
27
+ with pytest.raises(TokenValidationError, match="private key length"):
28
+ load_private_key(invalid_b64)
29
+ with pytest.raises(TokenValidationError, match="public key length"):
30
+ load_public_key(invalid_b64)
31
+
32
+
33
+ def test_decode_jws_rejects_invalid_compact_format() -> None:
34
+ with pytest.raises(TokenValidationError, match="invalid compact JWS format"):
35
+ decode_jws("only-two.parts")
36
+
37
+
38
+ def test_decode_jws_rejects_invalid_encoding_and_non_object_json() -> None:
39
+ with pytest.raises(TokenValidationError, match="invalid JWS encoding"):
40
+ decode_jws("a.b.c")
41
+
42
+ header = b64url_encode(b'["not-object"]')
43
+ payload = b64url_encode(b'{"ok":1}')
44
+ signature = b64url_encode(b"x" * 64)
45
+ token = f"{header}.{payload}.{signature}"
46
+ with pytest.raises(TokenValidationError, match="must be JSON objects"):
47
+ decode_jws(token)
48
+
49
+
50
+ def test_verify_jws_rejects_unsupported_alg() -> None:
51
+ private_b64, public_b64 = generate_ed25519_keypair()
52
+ private_key = load_private_key(private_b64)
53
+ public_key = load_public_key(public_b64)
54
+
55
+ header = b64url_encode(b'{"alg":"HS256","kid":"k1","typ":"rare.identity.public+jws"}')
56
+ payload = b64url_encode(b'{"typ":"rare.identity","ver":1}')
57
+ signing_input = f"{header}.{payload}".encode("ascii")
58
+ signature = b64url_encode(private_key.sign(signing_input))
59
+ token = f"{header}.{payload}.{signature}"
60
+ with pytest.raises(TokenValidationError, match="unsupported JWS alg"):
61
+ verify_jws(token, public_key)
62
+
63
+
64
+ def test_verify_jws_rejects_invalid_signature() -> None:
65
+ private_b64, public_b64 = generate_ed25519_keypair()
66
+ token = sign_jws(
67
+ payload={"typ": "rare.identity", "ver": 1, "iss": "rare"},
68
+ private_key=load_private_key(private_b64),
69
+ kid="k1",
70
+ typ="rare.identity.public+jws",
71
+ )
72
+ encoded_header, encoded_payload, encoded_signature = token.split(".")
73
+ signature_bytes = bytearray(b64url_decode(encoded_signature))
74
+ signature_bytes[0] ^= 0x01
75
+ tampered = f"{encoded_header}.{encoded_payload}.{b64url_encode(bytes(signature_bytes))}"
76
+ with pytest.raises(SignatureError, match="invalid JWS signature"):
77
+ verify_jws(tampered, load_public_key(public_b64))
78
+
79
+
80
+ def test_verify_detached_rejects_invalid_signature() -> None:
81
+ private_a_b64, public_a_b64 = generate_ed25519_keypair()
82
+ private_b_b64, _ = generate_ed25519_keypair()
83
+ message = "rare-auth-v1:platform:nonce:1:2"
84
+ wrong_sig = sign_detached(message, load_private_key(private_b_b64))
85
+ with pytest.raises(SignatureError, match="invalid detached signature"):
86
+ verify_detached(message, wrong_sig, load_public_key(public_a_b64))
87
+
88
+
89
+ def test_validate_name_normalizes_and_checks_boundaries() -> None:
90
+ assert validate_name(" Alice ") == "Alice"
91
+ assert validate_name("a") == "a"
92
+ assert validate_name("x" * 48) == "x" * 48
93
+
94
+ with pytest.raises(TokenValidationError, match="between 1 and 48"):
95
+ validate_name("")
96
+ with pytest.raises(TokenValidationError, match="between 1 and 48"):
97
+ validate_name("x" * 49)
98
+
99
+
100
+ def test_validate_name_rejects_control_and_reserved_words() -> None:
101
+ with pytest.raises(TokenValidationError, match="control characters"):
102
+ validate_name("hello\nworld")
103
+ with pytest.raises(TokenValidationError, match="control characters"):
104
+ validate_name("hello\u200Eworld")
105
+ with pytest.raises(TokenValidationError, match="reserved"):
106
+ validate_name("AdMiN")
107
+
108
+
109
+ def test_expiring_map_rejects_invalid_capacity_and_overflow() -> None:
110
+ with pytest.raises(ValueError, match="capacity must be greater than 0"):
111
+ ExpiringMap[int, int](capacity=0)
112
+
113
+ store = ExpiringMap[str, int](capacity=1)
114
+ store.set(key="k1", value=1, expires_at=100, now=0)
115
+ with pytest.raises(ResourceLimitError, match="capacity exceeded"):
116
+ store.set(key="k2", value=2, expires_at=100, now=0)
117
+
118
+
119
+ def test_expiring_map_cleanup_handles_stale_revisions() -> None:
120
+ store = ExpiringMap[str, str](capacity=4)
121
+ store.set(key="k", value="old", expires_at=10, now=0)
122
+ store.set(key="k", value="new", expires_at=200, now=0)
123
+ store.cleanup(now=50)
124
+ assert store.get("k") == "new"
125
+ store.cleanup(now=300)
126
+ assert store.get("k") is None
127
+
128
+
129
+ def test_expiring_map_helpers_pop_discard_keys_values_items() -> None:
130
+ store = ExpiringMap[str, int](capacity=4)
131
+ store.set(key="a", value=1, expires_at=100, now=0)
132
+ store.set(key="b", value=2, expires_at=100, now=0)
133
+
134
+ assert "a" in store
135
+ assert len(store) == 2
136
+ assert set(store.keys()) == {"a", "b"}
137
+ assert set(store.values()) == {1, 2}
138
+ assert set(store.items()) == {("a", 1), ("b", 2)}
139
+ assert store.pop("a") == 1
140
+ assert store.pop("missing") is None
141
+ store.discard("b")
142
+ store.discard("missing")
143
+ assert len(store) == 0
144
+
145
+
146
+ def test_expiring_set_helpers_cover_add_contains_discard_and_len() -> None:
147
+ seen = ExpiringSet[str](capacity=4)
148
+ seen.add(key="nonce-1", expires_at=100, now=0)
149
+ assert seen.contains("nonce-1")
150
+ assert len(seen) == 1
151
+ seen.discard("nonce-1")
152
+ seen.discard("nonce-404")
153
+ assert not seen.contains("nonce-1")
154
+ assert len(seen) == 0
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from rare_identity_protocol import (
7
+ build_action_payload,
8
+ build_auth_challenge_payload,
9
+ build_full_attestation_issue_payload,
10
+ build_register_payload,
11
+ build_set_name_payload,
12
+ build_upgrade_request_payload,
13
+ )
14
+
15
+
16
+ def test_rip_v1_signing_input_vectors() -> None:
17
+ root = Path(__file__).resolve().parent
18
+ vectors_path = root / "fixtures" / "rip-v1-signing-inputs.json"
19
+ vectors = json.loads(vectors_path.read_text(encoding="utf-8"))
20
+
21
+ challenge = vectors["challenge"]
22
+ assert build_auth_challenge_payload(**challenge["input"]) == challenge["expected"]
23
+
24
+ set_name = vectors["set_name"]
25
+ assert build_set_name_payload(**set_name["input"]) == set_name["expected"]
26
+
27
+ set_name_nfkc_trim = vectors["set_name_nfkc_trim"]
28
+ assert (
29
+ build_set_name_payload(**set_name_nfkc_trim["input"])
30
+ == set_name_nfkc_trim["expected"]
31
+ )
32
+
33
+ register = vectors["register"]
34
+ assert build_register_payload(**register["input"]) == register["expected"]
35
+
36
+ register_nfkc_trim = vectors["register_nfkc_trim"]
37
+ assert (
38
+ build_register_payload(**register_nfkc_trim["input"])
39
+ == register_nfkc_trim["expected"]
40
+ )
41
+
42
+ full_attestation_issue = vectors["full_attestation_issue"]
43
+ assert (
44
+ build_full_attestation_issue_payload(**full_attestation_issue["input"])
45
+ == full_attestation_issue["expected"]
46
+ )
47
+
48
+ upgrade_request = vectors["upgrade_request"]
49
+ assert (
50
+ build_upgrade_request_payload(**upgrade_request["input"])
51
+ == upgrade_request["expected"]
52
+ )
53
+
54
+ action_post = vectors["action_post"]
55
+ assert build_action_payload(**action_post["input"]) == action_post["expected"]