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.
- rare_identity_protocol-0.1.0/PKG-INFO +35 -0
- rare_identity_protocol-0.1.0/README.md +20 -0
- rare_identity_protocol-0.1.0/pyproject.toml +32 -0
- rare_identity_protocol-0.1.0/setup.cfg +4 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol/__init__.py +75 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol/actions.py +25 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol/challenge.py +76 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol/crypto.py +155 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol/errors.py +14 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol/expiring_store.py +107 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol/name_policy.py +41 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol/tokens.py +239 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol.egg-info/PKG-INFO +35 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol.egg-info/SOURCES.txt +17 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol.egg-info/dependency_links.txt +1 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol.egg-info/requires.txt +4 -0
- rare_identity_protocol-0.1.0/src/rare_identity_protocol.egg-info/top_level.txt +1 -0
- rare_identity_protocol-0.1.0/tests/test_protocol_unit.py +154 -0
- rare_identity_protocol-0.1.0/tests/test_vectors.py +55 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rare_identity_protocol
|
|
@@ -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"]
|