human-attestation 0.3.6__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.
- human_attestation-0.3.6/.gitignore +32 -0
- human_attestation-0.3.6/PKG-INFO +201 -0
- human_attestation-0.3.6/README.md +171 -0
- human_attestation-0.3.6/hap/__init__.py +100 -0
- human_attestation-0.3.6/hap/sign.py +186 -0
- human_attestation-0.3.6/hap/types.py +114 -0
- human_attestation-0.3.6/hap/verify.py +272 -0
- human_attestation-0.3.6/pyproject.toml +68 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# OS
|
|
2
|
+
.DS_Store
|
|
3
|
+
|
|
4
|
+
# AI assistant files
|
|
5
|
+
CLAUDE.md
|
|
6
|
+
|
|
7
|
+
# Dependencies
|
|
8
|
+
node_modules/
|
|
9
|
+
vendor/
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.pyc
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# Build outputs
|
|
16
|
+
dist/
|
|
17
|
+
build/
|
|
18
|
+
target/
|
|
19
|
+
bin/
|
|
20
|
+
obj/
|
|
21
|
+
|
|
22
|
+
# IDE
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
*.swp
|
|
26
|
+
*.swo
|
|
27
|
+
|
|
28
|
+
# Package manager locks (keep in individual SDK dirs)
|
|
29
|
+
# But ignore at root
|
|
30
|
+
/package-lock.json
|
|
31
|
+
/yarn.lock
|
|
32
|
+
.claude/
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: human-attestation
|
|
3
|
+
Version: 0.3.6
|
|
4
|
+
Summary: Official SDK for HAP (Human Attestation Protocol) - cryptographic proof of verified human effort
|
|
5
|
+
Project-URL: Homepage, https://github.com/Blue-Scroll/hap
|
|
6
|
+
Project-URL: Repository, https://github.com/Blue-Scroll/hap.git
|
|
7
|
+
Project-URL: Documentation, https://github.com/Blue-Scroll/hap#readme
|
|
8
|
+
Author: BlueScroll Inc.
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: attestation,cryptographic,ed25519,hap,human-attestation-protocol,jws,proof-of-effort,sender-verification,verification
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Security :: Cryptography
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: cryptography>=41.0.0
|
|
22
|
+
Requires-Dist: httpx>=0.25.0
|
|
23
|
+
Requires-Dist: pyjwt>=2.8.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# human-attestation
|
|
32
|
+
|
|
33
|
+
Official HAP (Human Attestation Protocol) SDK for Python.
|
|
34
|
+
|
|
35
|
+
HAP is an open standard for verified human effort. It enables Verification Authorities (VAs) to cryptographically attest that a sender took deliberate, costly action when communicating with a recipient.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install human-attestation
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
### Verifying a Claim (For Recipients)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
from hap import verify_hap_claim, is_claim_expired, is_claim_for_recipient
|
|
50
|
+
|
|
51
|
+
async def main():
|
|
52
|
+
# Verify a claim from a HAP ID
|
|
53
|
+
claim = await verify_hap_claim("hap_abc123xyz456", "ballista.jobs")
|
|
54
|
+
|
|
55
|
+
if claim:
|
|
56
|
+
# Check if not expired
|
|
57
|
+
if is_claim_expired(claim):
|
|
58
|
+
print("Claim has expired")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Verify it's for your organization
|
|
62
|
+
if not is_claim_for_recipient(claim, "yourcompany.com"):
|
|
63
|
+
print("Claim is for a different recipient")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
print(f"Verified {claim['method']} application to {claim['to']['name']}")
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Verifying from a URL
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from hap import extract_hap_id_from_url, verify_hap_claim
|
|
75
|
+
|
|
76
|
+
async def verify_from_url(url: str):
|
|
77
|
+
# Extract HAP ID from a verification URL
|
|
78
|
+
hap_id = extract_hap_id_from_url(url)
|
|
79
|
+
|
|
80
|
+
if hap_id:
|
|
81
|
+
claim = await verify_hap_claim(hap_id, "ballista.jobs")
|
|
82
|
+
return claim
|
|
83
|
+
return None
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Verifying Signature Manually
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from hap import fetch_claim, verify_signature
|
|
90
|
+
|
|
91
|
+
async def verify_with_signature(hap_id: str):
|
|
92
|
+
# Fetch the claim
|
|
93
|
+
response = await fetch_claim(hap_id, "ballista.jobs")
|
|
94
|
+
|
|
95
|
+
if response.get("valid") and "jws" in response:
|
|
96
|
+
# Verify the cryptographic signature
|
|
97
|
+
result = await verify_signature(response["jws"], "ballista.jobs")
|
|
98
|
+
|
|
99
|
+
if result["valid"]:
|
|
100
|
+
print("Signature verified!", result["claim"])
|
|
101
|
+
else:
|
|
102
|
+
print("Signature invalid:", result["error"])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Signing Claims (For Verification Authorities)
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import json
|
|
109
|
+
from hap import (
|
|
110
|
+
generate_key_pair,
|
|
111
|
+
export_public_key_jwk,
|
|
112
|
+
create_human_effort_claim,
|
|
113
|
+
sign_claim,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Generate a key pair (do this once, store securely)
|
|
117
|
+
private_key, public_key = generate_key_pair()
|
|
118
|
+
|
|
119
|
+
# Export public key for /.well-known/hap.json
|
|
120
|
+
jwk = export_public_key_jwk(public_key, "my_key_001")
|
|
121
|
+
well_known = {"issuer": "my-va.com", "keys": [jwk]}
|
|
122
|
+
print(json.dumps(well_known, indent=2))
|
|
123
|
+
|
|
124
|
+
# Create and sign a claim
|
|
125
|
+
claim = create_human_effort_claim(
|
|
126
|
+
method="physical_mail",
|
|
127
|
+
recipient_name="Acme Corp",
|
|
128
|
+
domain="acme.com",
|
|
129
|
+
tier="standard",
|
|
130
|
+
issuer="my-va.com",
|
|
131
|
+
expires_in_days=730, # 2 years
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
jws = sign_claim(claim, private_key, kid="my_key_001")
|
|
135
|
+
print("Signed JWS:", jws)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Creating Recipient Commitment Claims
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from hap import create_recipient_commitment_claim, sign_claim
|
|
142
|
+
|
|
143
|
+
claim = create_recipient_commitment_claim(
|
|
144
|
+
recipient_name="Acme Corp",
|
|
145
|
+
recipient_domain="acme.com",
|
|
146
|
+
commitment="review_verified",
|
|
147
|
+
issuer="my-va.com",
|
|
148
|
+
expires_in_days=365,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
jws = sign_claim(claim, private_key, kid="my_key_001")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## API Reference
|
|
155
|
+
|
|
156
|
+
### Verification Functions
|
|
157
|
+
|
|
158
|
+
| Function | Description |
|
|
159
|
+
| ------------------------------------- | ----------------------------------------------- |
|
|
160
|
+
| `verify_hap_claim(hap_id, issuer)` | Fetch and verify a claim, returns claim or None |
|
|
161
|
+
| `fetch_claim(hap_id, issuer)` | Fetch raw verification response from VA |
|
|
162
|
+
| `verify_signature(jws, issuer)` | Verify JWS signature against VA's public keys |
|
|
163
|
+
| `fetch_public_keys(issuer)` | Fetch VA's public keys from well-known endpoint |
|
|
164
|
+
| `is_valid_hap_id(id)` | Check if string matches HAP ID format |
|
|
165
|
+
| `extract_hap_id_from_url(url)` | Extract HAP ID from verification URL |
|
|
166
|
+
| `is_claim_expired(claim)` | Check if claim has passed expiration |
|
|
167
|
+
| `is_claim_for_recipient(claim, domain)` | Check if claim targets specific recipient |
|
|
168
|
+
|
|
169
|
+
### Signing Functions (For VAs)
|
|
170
|
+
|
|
171
|
+
| Function | Description |
|
|
172
|
+
| --------------------------------------- | ---------------------------------------- |
|
|
173
|
+
| `generate_key_pair()` | Generate Ed25519 key pair |
|
|
174
|
+
| `export_public_key_jwk(key, kid)` | Export public key as JWK |
|
|
175
|
+
| `sign_claim(claim, private_key, kid)` | Sign a claim, returns JWS |
|
|
176
|
+
| `generate_hap_id()` | Generate cryptographically secure HAP ID |
|
|
177
|
+
| `create_human_effort_claim(...)` | Create human_effort claim with defaults |
|
|
178
|
+
| `create_recipient_commitment_claim(...)` | Create recipient_commitment claim |
|
|
179
|
+
|
|
180
|
+
### Types
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from hap import (
|
|
184
|
+
HapClaim,
|
|
185
|
+
HumanEffortClaim,
|
|
186
|
+
RecipientCommitmentClaim,
|
|
187
|
+
VerificationResponse,
|
|
188
|
+
HapWellKnown,
|
|
189
|
+
HapJwk,
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Requirements
|
|
194
|
+
|
|
195
|
+
- Python 3.9+
|
|
196
|
+
- httpx (for async HTTP)
|
|
197
|
+
- PyJWT with cryptography
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
Apache-2.0
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# human-attestation
|
|
2
|
+
|
|
3
|
+
Official HAP (Human Attestation Protocol) SDK for Python.
|
|
4
|
+
|
|
5
|
+
HAP is an open standard for verified human effort. It enables Verification Authorities (VAs) to cryptographically attest that a sender took deliberate, costly action when communicating with a recipient.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install human-attestation
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### Verifying a Claim (For Recipients)
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import asyncio
|
|
19
|
+
from hap import verify_hap_claim, is_claim_expired, is_claim_for_recipient
|
|
20
|
+
|
|
21
|
+
async def main():
|
|
22
|
+
# Verify a claim from a HAP ID
|
|
23
|
+
claim = await verify_hap_claim("hap_abc123xyz456", "ballista.jobs")
|
|
24
|
+
|
|
25
|
+
if claim:
|
|
26
|
+
# Check if not expired
|
|
27
|
+
if is_claim_expired(claim):
|
|
28
|
+
print("Claim has expired")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
# Verify it's for your organization
|
|
32
|
+
if not is_claim_for_recipient(claim, "yourcompany.com"):
|
|
33
|
+
print("Claim is for a different recipient")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
print(f"Verified {claim['method']} application to {claim['to']['name']}")
|
|
37
|
+
|
|
38
|
+
asyncio.run(main())
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Verifying from a URL
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from hap import extract_hap_id_from_url, verify_hap_claim
|
|
45
|
+
|
|
46
|
+
async def verify_from_url(url: str):
|
|
47
|
+
# Extract HAP ID from a verification URL
|
|
48
|
+
hap_id = extract_hap_id_from_url(url)
|
|
49
|
+
|
|
50
|
+
if hap_id:
|
|
51
|
+
claim = await verify_hap_claim(hap_id, "ballista.jobs")
|
|
52
|
+
return claim
|
|
53
|
+
return None
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Verifying Signature Manually
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from hap import fetch_claim, verify_signature
|
|
60
|
+
|
|
61
|
+
async def verify_with_signature(hap_id: str):
|
|
62
|
+
# Fetch the claim
|
|
63
|
+
response = await fetch_claim(hap_id, "ballista.jobs")
|
|
64
|
+
|
|
65
|
+
if response.get("valid") and "jws" in response:
|
|
66
|
+
# Verify the cryptographic signature
|
|
67
|
+
result = await verify_signature(response["jws"], "ballista.jobs")
|
|
68
|
+
|
|
69
|
+
if result["valid"]:
|
|
70
|
+
print("Signature verified!", result["claim"])
|
|
71
|
+
else:
|
|
72
|
+
print("Signature invalid:", result["error"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Signing Claims (For Verification Authorities)
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import json
|
|
79
|
+
from hap import (
|
|
80
|
+
generate_key_pair,
|
|
81
|
+
export_public_key_jwk,
|
|
82
|
+
create_human_effort_claim,
|
|
83
|
+
sign_claim,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Generate a key pair (do this once, store securely)
|
|
87
|
+
private_key, public_key = generate_key_pair()
|
|
88
|
+
|
|
89
|
+
# Export public key for /.well-known/hap.json
|
|
90
|
+
jwk = export_public_key_jwk(public_key, "my_key_001")
|
|
91
|
+
well_known = {"issuer": "my-va.com", "keys": [jwk]}
|
|
92
|
+
print(json.dumps(well_known, indent=2))
|
|
93
|
+
|
|
94
|
+
# Create and sign a claim
|
|
95
|
+
claim = create_human_effort_claim(
|
|
96
|
+
method="physical_mail",
|
|
97
|
+
recipient_name="Acme Corp",
|
|
98
|
+
domain="acme.com",
|
|
99
|
+
tier="standard",
|
|
100
|
+
issuer="my-va.com",
|
|
101
|
+
expires_in_days=730, # 2 years
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
jws = sign_claim(claim, private_key, kid="my_key_001")
|
|
105
|
+
print("Signed JWS:", jws)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Creating Recipient Commitment Claims
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from hap import create_recipient_commitment_claim, sign_claim
|
|
112
|
+
|
|
113
|
+
claim = create_recipient_commitment_claim(
|
|
114
|
+
recipient_name="Acme Corp",
|
|
115
|
+
recipient_domain="acme.com",
|
|
116
|
+
commitment="review_verified",
|
|
117
|
+
issuer="my-va.com",
|
|
118
|
+
expires_in_days=365,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
jws = sign_claim(claim, private_key, kid="my_key_001")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## API Reference
|
|
125
|
+
|
|
126
|
+
### Verification Functions
|
|
127
|
+
|
|
128
|
+
| Function | Description |
|
|
129
|
+
| ------------------------------------- | ----------------------------------------------- |
|
|
130
|
+
| `verify_hap_claim(hap_id, issuer)` | Fetch and verify a claim, returns claim or None |
|
|
131
|
+
| `fetch_claim(hap_id, issuer)` | Fetch raw verification response from VA |
|
|
132
|
+
| `verify_signature(jws, issuer)` | Verify JWS signature against VA's public keys |
|
|
133
|
+
| `fetch_public_keys(issuer)` | Fetch VA's public keys from well-known endpoint |
|
|
134
|
+
| `is_valid_hap_id(id)` | Check if string matches HAP ID format |
|
|
135
|
+
| `extract_hap_id_from_url(url)` | Extract HAP ID from verification URL |
|
|
136
|
+
| `is_claim_expired(claim)` | Check if claim has passed expiration |
|
|
137
|
+
| `is_claim_for_recipient(claim, domain)` | Check if claim targets specific recipient |
|
|
138
|
+
|
|
139
|
+
### Signing Functions (For VAs)
|
|
140
|
+
|
|
141
|
+
| Function | Description |
|
|
142
|
+
| --------------------------------------- | ---------------------------------------- |
|
|
143
|
+
| `generate_key_pair()` | Generate Ed25519 key pair |
|
|
144
|
+
| `export_public_key_jwk(key, kid)` | Export public key as JWK |
|
|
145
|
+
| `sign_claim(claim, private_key, kid)` | Sign a claim, returns JWS |
|
|
146
|
+
| `generate_hap_id()` | Generate cryptographically secure HAP ID |
|
|
147
|
+
| `create_human_effort_claim(...)` | Create human_effort claim with defaults |
|
|
148
|
+
| `create_recipient_commitment_claim(...)` | Create recipient_commitment claim |
|
|
149
|
+
|
|
150
|
+
### Types
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from hap import (
|
|
154
|
+
HapClaim,
|
|
155
|
+
HumanEffortClaim,
|
|
156
|
+
RecipientCommitmentClaim,
|
|
157
|
+
VerificationResponse,
|
|
158
|
+
HapWellKnown,
|
|
159
|
+
HapJwk,
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Requirements
|
|
164
|
+
|
|
165
|
+
- Python 3.9+
|
|
166
|
+
- httpx (for async HTTP)
|
|
167
|
+
- PyJWT with cryptography
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
Apache-2.0
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HAP (Human Attestation Protocol) SDK for Python
|
|
3
|
+
|
|
4
|
+
HAP is an open standard for verified human effort. It enables Verification
|
|
5
|
+
Authorities (VAs) to cryptographically attest that a sender took deliberate,
|
|
6
|
+
costly action when communicating with a recipient.
|
|
7
|
+
|
|
8
|
+
Example - Verifying a claim (for recipients):
|
|
9
|
+
>>> import asyncio
|
|
10
|
+
>>> from hap import verify_hap_claim, is_claim_expired
|
|
11
|
+
>>>
|
|
12
|
+
>>> async def main():
|
|
13
|
+
... claim = await verify_hap_claim("hap_abc123xyz456", "ballista.jobs")
|
|
14
|
+
... if claim and not is_claim_expired(claim):
|
|
15
|
+
... print(f"Verified application to {claim['to']['name']}")
|
|
16
|
+
>>>
|
|
17
|
+
>>> asyncio.run(main())
|
|
18
|
+
|
|
19
|
+
Example - Signing a claim (for VAs):
|
|
20
|
+
>>> from hap import generate_key_pair, sign_claim, create_human_effort_claim
|
|
21
|
+
>>>
|
|
22
|
+
>>> private_key, public_key = generate_key_pair()
|
|
23
|
+
>>> claim = create_human_effort_claim(
|
|
24
|
+
... method="physical_mail",
|
|
25
|
+
... recipient_name="Acme Corp",
|
|
26
|
+
... domain="acme.com",
|
|
27
|
+
... issuer="my-va.com",
|
|
28
|
+
... )
|
|
29
|
+
>>> jws = sign_claim(claim, private_key, kid="key_001")
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from hap.types import (
|
|
33
|
+
HAP_ID_REGEX,
|
|
34
|
+
HAP_VERSION,
|
|
35
|
+
ClaimType,
|
|
36
|
+
CommitmentLevel,
|
|
37
|
+
RecipientCommitmentClaim,
|
|
38
|
+
HapClaim,
|
|
39
|
+
HapJwk,
|
|
40
|
+
HapWellKnown,
|
|
41
|
+
HumanEffortClaim,
|
|
42
|
+
RevocationReason,
|
|
43
|
+
VerificationMethod,
|
|
44
|
+
VerificationResponse,
|
|
45
|
+
)
|
|
46
|
+
from hap.verify import (
|
|
47
|
+
extract_hap_id_from_url,
|
|
48
|
+
fetch_claim,
|
|
49
|
+
fetch_public_keys,
|
|
50
|
+
is_claim_expired,
|
|
51
|
+
is_claim_for_recipient,
|
|
52
|
+
is_valid_hap_id,
|
|
53
|
+
verify_hap_claim,
|
|
54
|
+
verify_signature,
|
|
55
|
+
)
|
|
56
|
+
from hap.sign import (
|
|
57
|
+
create_recipient_commitment_claim,
|
|
58
|
+
create_human_effort_claim,
|
|
59
|
+
export_public_key_jwk,
|
|
60
|
+
generate_hap_id,
|
|
61
|
+
generate_key_pair,
|
|
62
|
+
sign_claim,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
__version__ = "0.3.6"
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
# Version
|
|
69
|
+
"__version__",
|
|
70
|
+
# Constants
|
|
71
|
+
"HAP_ID_REGEX",
|
|
72
|
+
"HAP_VERSION",
|
|
73
|
+
# Types
|
|
74
|
+
"ClaimType",
|
|
75
|
+
"CommitmentLevel",
|
|
76
|
+
"RecipientCommitmentClaim",
|
|
77
|
+
"HapClaim",
|
|
78
|
+
"HapJwk",
|
|
79
|
+
"HapWellKnown",
|
|
80
|
+
"HumanEffortClaim",
|
|
81
|
+
"RevocationReason",
|
|
82
|
+
"VerificationMethod",
|
|
83
|
+
"VerificationResponse",
|
|
84
|
+
# Verification functions
|
|
85
|
+
"extract_hap_id_from_url",
|
|
86
|
+
"fetch_claim",
|
|
87
|
+
"fetch_public_keys",
|
|
88
|
+
"is_claim_expired",
|
|
89
|
+
"is_claim_for_recipient",
|
|
90
|
+
"is_valid_hap_id",
|
|
91
|
+
"verify_hap_claim",
|
|
92
|
+
"verify_signature",
|
|
93
|
+
# Signing functions
|
|
94
|
+
"create_recipient_commitment_claim",
|
|
95
|
+
"create_human_effort_claim",
|
|
96
|
+
"export_public_key_jwk",
|
|
97
|
+
"generate_hap_id",
|
|
98
|
+
"generate_key_pair",
|
|
99
|
+
"sign_claim",
|
|
100
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HAP claim signing functions (for Verification Authorities)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
import string
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import jwt
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
14
|
+
Ed25519PrivateKey,
|
|
15
|
+
Ed25519PublicKey,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from hap.types import HAP_VERSION, RecipientCommitmentClaim, HapClaim, HumanEffortClaim
|
|
19
|
+
|
|
20
|
+
# Characters used for HAP ID generation
|
|
21
|
+
HAP_ID_CHARS = string.ascii_letters + string.digits
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_hap_id() -> str:
|
|
25
|
+
"""
|
|
26
|
+
Generates a cryptographically secure random HAP ID.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
A HAP ID in the format hap_[a-zA-Z0-9]{12}
|
|
30
|
+
"""
|
|
31
|
+
suffix = "".join(secrets.choice(HAP_ID_CHARS) for _ in range(12))
|
|
32
|
+
return f"hap_{suffix}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_key_pair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
|
|
36
|
+
"""
|
|
37
|
+
Generates a new Ed25519 key pair for signing HAP claims.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tuple of (private_key, public_key)
|
|
41
|
+
"""
|
|
42
|
+
private_key = Ed25519PrivateKey.generate()
|
|
43
|
+
public_key = private_key.public_key()
|
|
44
|
+
return private_key, public_key
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def export_public_key_jwk(public_key: Ed25519PublicKey, kid: str) -> dict[str, Any]:
|
|
48
|
+
"""
|
|
49
|
+
Exports a public key to JWK format suitable for /.well-known/hap.json
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
public_key: The public key to export
|
|
53
|
+
kid: The key ID to assign
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
JWK dict
|
|
57
|
+
"""
|
|
58
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
59
|
+
|
|
60
|
+
# Get the raw public key bytes
|
|
61
|
+
raw_bytes = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
62
|
+
|
|
63
|
+
# Base64url encode without padding
|
|
64
|
+
x = base64.urlsafe_b64encode(raw_bytes).rstrip(b"=").decode("ascii")
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"kid": kid,
|
|
68
|
+
"kty": "OKP",
|
|
69
|
+
"crv": "Ed25519",
|
|
70
|
+
"x": x,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sign_claim(claim: HapClaim, private_key: Ed25519PrivateKey, kid: str) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Signs a HAP claim with an Ed25519 private key.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
claim: The claim to sign
|
|
80
|
+
private_key: The Ed25519 private key
|
|
81
|
+
kid: Key ID to include in JWS header
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
JWS compact serialization string
|
|
85
|
+
"""
|
|
86
|
+
# Ensure version is set
|
|
87
|
+
claim_with_version = {**claim, "v": claim.get("v", HAP_VERSION)}
|
|
88
|
+
|
|
89
|
+
# Sign using PyJWT
|
|
90
|
+
jws = jwt.encode(
|
|
91
|
+
claim_with_version,
|
|
92
|
+
private_key,
|
|
93
|
+
algorithm="EdDSA",
|
|
94
|
+
headers={"kid": kid},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return jws
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_human_effort_claim(
|
|
101
|
+
method: str,
|
|
102
|
+
recipient_name: str,
|
|
103
|
+
issuer: str,
|
|
104
|
+
domain: Optional[str] = None,
|
|
105
|
+
tier: Optional[str] = None,
|
|
106
|
+
expires_in_days: Optional[int] = None,
|
|
107
|
+
) -> HumanEffortClaim:
|
|
108
|
+
"""
|
|
109
|
+
Creates a complete human effort claim with all required fields.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
method: Verification method (e.g., "physical_mail")
|
|
113
|
+
recipient_name: Recipient name
|
|
114
|
+
issuer: VA's domain
|
|
115
|
+
domain: Recipient domain (optional)
|
|
116
|
+
tier: Service tier (optional)
|
|
117
|
+
expires_in_days: Days until expiration (optional)
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
A complete HumanEffortClaim dict
|
|
121
|
+
"""
|
|
122
|
+
now = datetime.now(timezone.utc)
|
|
123
|
+
|
|
124
|
+
claim: HumanEffortClaim = {
|
|
125
|
+
"v": HAP_VERSION,
|
|
126
|
+
"id": generate_hap_id(),
|
|
127
|
+
"type": "human_effort",
|
|
128
|
+
"method": method,
|
|
129
|
+
"to": {"name": recipient_name},
|
|
130
|
+
"at": now.isoformat().replace("+00:00", "Z"),
|
|
131
|
+
"iss": issuer,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if domain:
|
|
135
|
+
claim["to"]["domain"] = domain
|
|
136
|
+
|
|
137
|
+
if tier:
|
|
138
|
+
claim["tier"] = tier
|
|
139
|
+
|
|
140
|
+
if expires_in_days:
|
|
141
|
+
exp = now + timedelta(days=expires_in_days)
|
|
142
|
+
claim["exp"] = exp.isoformat().replace("+00:00", "Z")
|
|
143
|
+
|
|
144
|
+
return claim
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def create_recipient_commitment_claim(
|
|
148
|
+
recipient_name: str,
|
|
149
|
+
commitment: str,
|
|
150
|
+
issuer: str,
|
|
151
|
+
recipient_domain: Optional[str] = None,
|
|
152
|
+
expires_in_days: Optional[int] = None,
|
|
153
|
+
) -> RecipientCommitmentClaim:
|
|
154
|
+
"""
|
|
155
|
+
Creates a complete recipient commitment claim with all required fields.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
recipient_name: Recipient's name
|
|
159
|
+
commitment: Commitment level (e.g., "review_verified")
|
|
160
|
+
issuer: VA's domain
|
|
161
|
+
recipient_domain: Recipient's domain (optional)
|
|
162
|
+
expires_in_days: Days until expiration (optional)
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A complete RecipientCommitmentClaim dict
|
|
166
|
+
"""
|
|
167
|
+
now = datetime.now(timezone.utc)
|
|
168
|
+
|
|
169
|
+
claim: RecipientCommitmentClaim = {
|
|
170
|
+
"v": HAP_VERSION,
|
|
171
|
+
"id": generate_hap_id(),
|
|
172
|
+
"type": "recipient_commitment",
|
|
173
|
+
"recipient": {"name": recipient_name},
|
|
174
|
+
"commitment": commitment,
|
|
175
|
+
"at": now.isoformat().replace("+00:00", "Z"),
|
|
176
|
+
"iss": issuer,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if recipient_domain:
|
|
180
|
+
claim["recipient"]["domain"] = recipient_domain
|
|
181
|
+
|
|
182
|
+
if expires_in_days:
|
|
183
|
+
exp = now + timedelta(days=expires_in_days)
|
|
184
|
+
claim["exp"] = exp.isoformat().replace("+00:00", "Z")
|
|
185
|
+
|
|
186
|
+
return claim
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HAP (Human Attestation Protocol) type definitions for Python
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Literal, TypedDict, Union
|
|
7
|
+
|
|
8
|
+
# Protocol version
|
|
9
|
+
HAP_VERSION = "0.1"
|
|
10
|
+
|
|
11
|
+
# HAP ID format: hap_ followed by 12 alphanumeric characters
|
|
12
|
+
HAP_ID_REGEX = re.compile(r"^hap_[a-zA-Z0-9]{12}$")
|
|
13
|
+
|
|
14
|
+
# Type aliases
|
|
15
|
+
ClaimType = Literal["human_effort", "recipient_commitment"]
|
|
16
|
+
VerificationMethod = Literal["physical_mail", "video_interview", "paid_assessment", "referral"]
|
|
17
|
+
CommitmentLevel = Literal["review_verified", "prioritize_verified", "respond_verified"]
|
|
18
|
+
RevocationReason = Literal["fraud", "error", "legal", "user_request"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClaimTarget(TypedDict, total=False):
|
|
22
|
+
"""Target recipient information"""
|
|
23
|
+
name: str
|
|
24
|
+
domain: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RecipientInfo(TypedDict, total=False):
|
|
28
|
+
"""Recipient information for recipient_commitment claims"""
|
|
29
|
+
name: str
|
|
30
|
+
domain: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HumanEffortClaim(TypedDict, total=False):
|
|
34
|
+
"""Human effort verification claim"""
|
|
35
|
+
v: str
|
|
36
|
+
id: str
|
|
37
|
+
type: Literal["human_effort"]
|
|
38
|
+
method: str
|
|
39
|
+
tier: str
|
|
40
|
+
to: ClaimTarget
|
|
41
|
+
at: str
|
|
42
|
+
exp: str
|
|
43
|
+
iss: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RecipientCommitmentClaim(TypedDict, total=False):
|
|
47
|
+
"""Recipient commitment claim"""
|
|
48
|
+
v: str
|
|
49
|
+
id: str
|
|
50
|
+
type: Literal["recipient_commitment"]
|
|
51
|
+
recipient: RecipientInfo
|
|
52
|
+
commitment: str
|
|
53
|
+
at: str
|
|
54
|
+
exp: str
|
|
55
|
+
iss: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Union of all claim types
|
|
59
|
+
HapClaim = Union[HumanEffortClaim, RecipientCommitmentClaim]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class HapJwk(TypedDict):
|
|
63
|
+
"""JWK public key for Ed25519"""
|
|
64
|
+
kid: str
|
|
65
|
+
kty: Literal["OKP"]
|
|
66
|
+
crv: Literal["Ed25519"]
|
|
67
|
+
x: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HapWellKnown(TypedDict):
|
|
71
|
+
"""Response from /.well-known/hap.json"""
|
|
72
|
+
issuer: str
|
|
73
|
+
keys: list[HapJwk]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class VerificationResponseValid(TypedDict):
|
|
77
|
+
"""Successful verification response"""
|
|
78
|
+
valid: Literal[True]
|
|
79
|
+
id: str
|
|
80
|
+
claims: HapClaim
|
|
81
|
+
jws: str
|
|
82
|
+
issuer: str
|
|
83
|
+
verifyUrl: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class VerificationResponseRevoked(TypedDict):
|
|
87
|
+
"""Revoked claim response"""
|
|
88
|
+
valid: Literal[False]
|
|
89
|
+
id: str
|
|
90
|
+
revoked: Literal[True]
|
|
91
|
+
revocationReason: RevocationReason
|
|
92
|
+
revokedAt: str
|
|
93
|
+
issuer: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class VerificationResponseNotFound(TypedDict):
|
|
97
|
+
"""Not found response"""
|
|
98
|
+
valid: Literal[False]
|
|
99
|
+
error: Literal["not_found"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class VerificationResponseInvalidFormat(TypedDict):
|
|
103
|
+
"""Invalid format response"""
|
|
104
|
+
valid: Literal[False]
|
|
105
|
+
error: Literal["invalid_format"]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Union of all verification response types
|
|
109
|
+
VerificationResponse = Union[
|
|
110
|
+
VerificationResponseValid,
|
|
111
|
+
VerificationResponseRevoked,
|
|
112
|
+
VerificationResponseNotFound,
|
|
113
|
+
VerificationResponseInvalidFormat,
|
|
114
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HAP claim verification functions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import jwt
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
13
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
14
|
+
|
|
15
|
+
from hap.types import (
|
|
16
|
+
HAP_ID_REGEX,
|
|
17
|
+
HapClaim,
|
|
18
|
+
HapJwk,
|
|
19
|
+
HapWellKnown,
|
|
20
|
+
VerificationResponse,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_valid_hap_id(hap_id: str) -> bool:
|
|
25
|
+
"""
|
|
26
|
+
Validates a HAP ID format.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
hap_id: The HAP ID to validate
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
True if the ID matches the format hap_[a-zA-Z0-9]{12}
|
|
33
|
+
"""
|
|
34
|
+
return bool(HAP_ID_REGEX.match(hap_id))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def fetch_public_keys(
|
|
38
|
+
issuer_domain: str,
|
|
39
|
+
timeout: float = 10.0,
|
|
40
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
41
|
+
) -> HapWellKnown:
|
|
42
|
+
"""
|
|
43
|
+
Fetches the public keys from a VA's well-known endpoint.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
issuer_domain: The VA's domain (e.g., "ballista.jobs")
|
|
47
|
+
timeout: Request timeout in seconds
|
|
48
|
+
client: Optional httpx client to use
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The VA's public key configuration
|
|
52
|
+
"""
|
|
53
|
+
url = f"https://{issuer_domain}/.well-known/hap.json"
|
|
54
|
+
|
|
55
|
+
if client:
|
|
56
|
+
response = await client.get(url, timeout=timeout)
|
|
57
|
+
else:
|
|
58
|
+
async with httpx.AsyncClient() as c:
|
|
59
|
+
response = await c.get(url, timeout=timeout)
|
|
60
|
+
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
return response.json()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def fetch_claim(
|
|
66
|
+
hap_id: str,
|
|
67
|
+
issuer_domain: str,
|
|
68
|
+
timeout: float = 10.0,
|
|
69
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
70
|
+
) -> VerificationResponse:
|
|
71
|
+
"""
|
|
72
|
+
Fetches and verifies a HAP claim from a VA.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
hap_id: The HAP ID to verify
|
|
76
|
+
issuer_domain: The VA's domain (e.g., "ballista.jobs")
|
|
77
|
+
timeout: Request timeout in seconds
|
|
78
|
+
client: Optional httpx client to use
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The verification response from the VA
|
|
82
|
+
"""
|
|
83
|
+
if not is_valid_hap_id(hap_id):
|
|
84
|
+
return {"valid": False, "error": "invalid_format"}
|
|
85
|
+
|
|
86
|
+
url = f"https://{issuer_domain}/api/v1/verify/{hap_id}"
|
|
87
|
+
|
|
88
|
+
if client:
|
|
89
|
+
response = await client.get(url, timeout=timeout)
|
|
90
|
+
else:
|
|
91
|
+
async with httpx.AsyncClient() as c:
|
|
92
|
+
response = await c.get(url, timeout=timeout)
|
|
93
|
+
|
|
94
|
+
return response.json()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _base64url_decode(data: str) -> bytes:
|
|
98
|
+
"""Decode base64url string to bytes."""
|
|
99
|
+
import base64
|
|
100
|
+
|
|
101
|
+
# Add padding if needed
|
|
102
|
+
padding = 4 - len(data) % 4
|
|
103
|
+
if padding != 4:
|
|
104
|
+
data += "=" * padding
|
|
105
|
+
return base64.urlsafe_b64decode(data)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _jwk_to_public_key(jwk: HapJwk) -> Ed25519PublicKey:
|
|
109
|
+
"""Convert JWK to Ed25519 public key."""
|
|
110
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
111
|
+
|
|
112
|
+
x_bytes = _base64url_decode(jwk["x"])
|
|
113
|
+
return Ed25519PublicKey.from_public_bytes(x_bytes)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def verify_signature(
|
|
117
|
+
jws: str,
|
|
118
|
+
issuer_domain: str,
|
|
119
|
+
timeout: float = 10.0,
|
|
120
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
"""
|
|
123
|
+
Verifies a JWS signature against a VA's public keys.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
jws: The JWS compact serialization string
|
|
127
|
+
issuer_domain: The VA's domain to fetch public keys from
|
|
128
|
+
timeout: Request timeout in seconds
|
|
129
|
+
client: Optional httpx client to use
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Dict with 'valid' boolean, 'claim' if valid, 'error' if invalid
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
# Fetch public keys from the VA
|
|
136
|
+
well_known = await fetch_public_keys(issuer_domain, timeout, client)
|
|
137
|
+
|
|
138
|
+
# Parse the JWS header to get the key ID
|
|
139
|
+
header = jwt.get_unverified_header(jws)
|
|
140
|
+
kid = header.get("kid")
|
|
141
|
+
|
|
142
|
+
if not kid:
|
|
143
|
+
return {"valid": False, "error": "JWS header missing kid"}
|
|
144
|
+
|
|
145
|
+
# Find the matching key
|
|
146
|
+
jwk = next((k for k in well_known["keys"] if k["kid"] == kid), None)
|
|
147
|
+
if not jwk:
|
|
148
|
+
return {"valid": False, "error": f"Key not found: {kid}"}
|
|
149
|
+
|
|
150
|
+
# Convert JWK to public key
|
|
151
|
+
public_key = _jwk_to_public_key(jwk)
|
|
152
|
+
|
|
153
|
+
# Verify the signature using PyJWT
|
|
154
|
+
claim = jwt.decode(
|
|
155
|
+
jws,
|
|
156
|
+
public_key,
|
|
157
|
+
algorithms=["EdDSA"],
|
|
158
|
+
options={"verify_aud": False},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Verify the issuer matches
|
|
162
|
+
if claim.get("iss") != issuer_domain:
|
|
163
|
+
return {
|
|
164
|
+
"valid": False,
|
|
165
|
+
"error": f"Issuer mismatch: expected {issuer_domain}, got {claim.get('iss')}",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {"valid": True, "claim": claim}
|
|
169
|
+
|
|
170
|
+
except Exception as e:
|
|
171
|
+
return {"valid": False, "error": str(e)}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def verify_hap_claim(
|
|
175
|
+
hap_id: str,
|
|
176
|
+
issuer_domain: str,
|
|
177
|
+
verify_sig: bool = True,
|
|
178
|
+
timeout: float = 10.0,
|
|
179
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
180
|
+
) -> Optional[HapClaim]:
|
|
181
|
+
"""
|
|
182
|
+
Fully verifies a HAP claim: fetches from VA and optionally verifies signature.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
hap_id: The HAP ID to verify
|
|
186
|
+
issuer_domain: The VA's domain
|
|
187
|
+
verify_sig: Whether to verify the cryptographic signature
|
|
188
|
+
timeout: Request timeout in seconds
|
|
189
|
+
client: Optional httpx client to use
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The claim if valid, None if not found or invalid
|
|
193
|
+
"""
|
|
194
|
+
# Fetch the claim from the VA
|
|
195
|
+
response = await fetch_claim(hap_id, issuer_domain, timeout, client)
|
|
196
|
+
|
|
197
|
+
# Check if valid
|
|
198
|
+
if not response.get("valid"):
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
# Optionally verify the signature
|
|
202
|
+
if verify_sig and "jws" in response:
|
|
203
|
+
sig_result = await verify_signature(response["jws"], issuer_domain, timeout, client)
|
|
204
|
+
if not sig_result.get("valid"):
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
return response.get("claims")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def extract_hap_id_from_url(url: str) -> Optional[str]:
|
|
211
|
+
"""
|
|
212
|
+
Extracts the HAP ID from a verification URL.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
url: The verification URL (e.g., "https://www.ballista.jobs/v/hap_abc123xyz456")
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The HAP ID or None if not found
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
parsed = urlparse(url)
|
|
222
|
+
path_parts = parsed.path.split("/")
|
|
223
|
+
last_part = path_parts[-1] if path_parts else ""
|
|
224
|
+
|
|
225
|
+
if is_valid_hap_id(last_part):
|
|
226
|
+
return last_part
|
|
227
|
+
|
|
228
|
+
return None
|
|
229
|
+
except Exception:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def is_claim_expired(claim: HapClaim) -> bool:
|
|
234
|
+
"""
|
|
235
|
+
Checks if a claim is expired.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
claim: The HAP claim to check
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if the claim has an exp field and is expired
|
|
242
|
+
"""
|
|
243
|
+
exp = claim.get("exp")
|
|
244
|
+
if not exp:
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
exp_date = datetime.fromisoformat(exp.replace("Z", "+00:00"))
|
|
248
|
+
return exp_date < datetime.now(timezone.utc)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def is_claim_for_recipient(claim: HapClaim, recipient_domain: str) -> bool:
|
|
252
|
+
"""
|
|
253
|
+
Checks if the claim target matches the expected recipient.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
claim: The HAP claim to check
|
|
257
|
+
recipient_domain: The expected recipient domain
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if the claim's target domain matches
|
|
261
|
+
"""
|
|
262
|
+
claim_type = claim.get("type")
|
|
263
|
+
|
|
264
|
+
if claim_type == "human_effort":
|
|
265
|
+
to = claim.get("to", {})
|
|
266
|
+
return to.get("domain") == recipient_domain
|
|
267
|
+
|
|
268
|
+
if claim_type == "recipient_commitment":
|
|
269
|
+
recipient = claim.get("recipient", {})
|
|
270
|
+
return recipient.get("domain") == recipient_domain
|
|
271
|
+
|
|
272
|
+
return False
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "human-attestation"
|
|
7
|
+
version = "0.3.6"
|
|
8
|
+
description = "Official SDK for HAP (Human Attestation Protocol) - cryptographic proof of verified human effort"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "BlueScroll Inc." }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"hap",
|
|
17
|
+
"human-attestation-protocol",
|
|
18
|
+
"attestation",
|
|
19
|
+
"verification",
|
|
20
|
+
"proof-of-effort",
|
|
21
|
+
"cryptographic",
|
|
22
|
+
"ed25519",
|
|
23
|
+
"jws",
|
|
24
|
+
"sender-verification"
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: Apache Software License",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.9",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
"Programming Language :: Python :: 3.11",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Topic :: Security :: Cryptography",
|
|
36
|
+
]
|
|
37
|
+
dependencies = [
|
|
38
|
+
"PyJWT>=2.8.0",
|
|
39
|
+
"cryptography>=41.0.0",
|
|
40
|
+
"httpx>=0.25.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=7.0.0",
|
|
46
|
+
"pytest-asyncio>=0.21.0",
|
|
47
|
+
"ruff>=0.1.0",
|
|
48
|
+
"mypy>=1.0.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[project.urls]
|
|
52
|
+
Homepage = "https://github.com/Blue-Scroll/hap"
|
|
53
|
+
Repository = "https://github.com/Blue-Scroll/hap.git"
|
|
54
|
+
Documentation = "https://github.com/Blue-Scroll/hap#readme"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.wheel]
|
|
57
|
+
packages = ["hap"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
target-version = "py39"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "W"]
|
|
65
|
+
|
|
66
|
+
[tool.mypy]
|
|
67
|
+
python_version = "3.9"
|
|
68
|
+
strict = true
|