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.
@@ -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