human-attestation 0.3.7 → 0.4.2
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.
- checksums.yaml +4 -4
- data/README.md +35 -66
- data/lib/human_attestation/compact.rb +238 -0
- data/lib/{hap → human_attestation}/sign.rb +46 -40
- data/lib/human_attestation/types.rb +18 -0
- data/lib/{hap → human_attestation}/verify.rb +8 -15
- data/lib/human_attestation/version.rb +5 -0
- data/lib/human_attestation.rb +50 -0
- metadata +7 -6
- data/lib/hap/types.rb +0 -24
- data/lib/hap/version.rb +0 -5
- data/lib/hap.rb +0 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43f2e6d60bc535f28ceae3b2c18c686c9f73b4e354904c817c6da92fc6160aa6
|
|
4
|
+
data.tar.gz: c99e2870ab4e166121dc6c0821f4e41610bdb65707ddbf5bbc4b34ca1c0118c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 257c57249f1957a4aa1c335392fa003a6e1b93a38a391a9bb71f2e20bfdb7c9add13f863029026847f2c73041e22d7545609b6fe5b87d0c766a734e57f2d3cf6
|
|
7
|
+
data.tar.gz: e1edae08ce9e8aaedfe69d283fb75390c04b28eb74830e00b56185b93d839f7c1625497590141dc5c0bbf00d8a25b2ca452d9df20b0c3d77063a5d60f43c2415
|
data/README.md
CHANGED
|
@@ -23,20 +23,20 @@ gem install human-attestation
|
|
|
23
23
|
### Verifying a Claim (For Recipients)
|
|
24
24
|
|
|
25
25
|
```ruby
|
|
26
|
-
require '
|
|
26
|
+
require 'human_attestation'
|
|
27
27
|
|
|
28
28
|
# Verify a claim from a HAP ID
|
|
29
|
-
claim =
|
|
29
|
+
claim = HumanAttestation.verify_claim("hap_abc123xyz456", "ballista.jobs")
|
|
30
30
|
|
|
31
31
|
if claim
|
|
32
32
|
# Check if not expired
|
|
33
|
-
if
|
|
33
|
+
if HumanAttestation.claim_expired?(claim)
|
|
34
34
|
puts "Claim has expired"
|
|
35
35
|
return
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
# Verify it's for your organization
|
|
39
|
-
unless
|
|
39
|
+
unless HumanAttestation.claim_for_recipient?(claim, "yourcompany.com")
|
|
40
40
|
puts "Claim is for a different recipient"
|
|
41
41
|
return
|
|
42
42
|
end
|
|
@@ -50,10 +50,10 @@ end
|
|
|
50
50
|
```ruby
|
|
51
51
|
# Extract HAP ID from a verification URL
|
|
52
52
|
url = "https://www.ballista.jobs/v/hap_abc123xyz456"
|
|
53
|
-
hap_id =
|
|
53
|
+
hap_id = HumanAttestation.extract_id_from_url(url)
|
|
54
54
|
|
|
55
55
|
if hap_id
|
|
56
|
-
claim =
|
|
56
|
+
claim = HumanAttestation.verify_claim(hap_id, "ballista.jobs")
|
|
57
57
|
# ... handle claim
|
|
58
58
|
end
|
|
59
59
|
```
|
|
@@ -62,11 +62,11 @@ end
|
|
|
62
62
|
|
|
63
63
|
```ruby
|
|
64
64
|
# Fetch the claim
|
|
65
|
-
response =
|
|
65
|
+
response = HumanAttestation.fetch_claim("hap_abc123xyz456", "ballista.jobs")
|
|
66
66
|
|
|
67
67
|
if response[:valid] && response[:jws]
|
|
68
68
|
# Verify the cryptographic signature
|
|
69
|
-
result =
|
|
69
|
+
result = HumanAttestation.verify_signature(response[:jws], "ballista.jobs")
|
|
70
70
|
|
|
71
71
|
if result[:valid]
|
|
72
72
|
puts "Signature verified! Claim: #{result[:claim]}"
|
|
@@ -79,89 +79,58 @@ end
|
|
|
79
79
|
### Signing Claims (For Verification Authorities)
|
|
80
80
|
|
|
81
81
|
```ruby
|
|
82
|
-
require '
|
|
82
|
+
require 'human_attestation'
|
|
83
83
|
require 'json'
|
|
84
84
|
|
|
85
85
|
# Generate a key pair (do this once, store securely)
|
|
86
|
-
private_key, public_key =
|
|
86
|
+
private_key, public_key = HumanAttestation.generate_key_pair
|
|
87
87
|
|
|
88
88
|
# Export public key for /.well-known/hap.json
|
|
89
|
-
jwk =
|
|
89
|
+
jwk = HumanAttestation.export_public_key_jwk(public_key, "my_key_001")
|
|
90
90
|
well_known = { issuer: "my-va.com", keys: [jwk] }
|
|
91
91
|
puts JSON.pretty_generate(well_known)
|
|
92
92
|
|
|
93
93
|
# Create and sign a claim
|
|
94
|
-
claim =
|
|
94
|
+
claim = HumanAttestation.create_claim(
|
|
95
95
|
method: "physical_mail",
|
|
96
|
+
description: "Priority mail packet with handwritten cover letter",
|
|
96
97
|
recipient_name: "Acme Corp",
|
|
97
98
|
domain: "acme.com",
|
|
98
|
-
tier: "standard",
|
|
99
99
|
issuer: "my-va.com",
|
|
100
|
-
expires_in_days: 730 # 2 years
|
|
100
|
+
expires_in_days: 730, # 2 years
|
|
101
|
+
cost: { amount: 1500, currency: "USD" },
|
|
102
|
+
time: 1800,
|
|
103
|
+
physical: true
|
|
101
104
|
)
|
|
102
105
|
|
|
103
|
-
jws =
|
|
106
|
+
jws = HumanAttestation.sign_claim(claim, private_key, kid: "my_key_001")
|
|
104
107
|
puts "Signed JWS: #{jws}"
|
|
105
108
|
```
|
|
106
109
|
|
|
107
|
-
### Creating Recipient Commitment Claims
|
|
108
|
-
|
|
109
|
-
```ruby
|
|
110
|
-
claim = Hap.create_recipient_commitment_claim(
|
|
111
|
-
recipient_name: "Acme Corp",
|
|
112
|
-
recipient_domain: "acme.com",
|
|
113
|
-
commitment: "review_verified",
|
|
114
|
-
issuer: "my-va.com",
|
|
115
|
-
expires_in_days: 365
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
jws = Hap.sign_claim(claim, private_key, kid: "my_key_001")
|
|
119
|
-
```
|
|
120
|
-
|
|
121
110
|
## API Reference
|
|
122
111
|
|
|
123
112
|
### Verification Functions
|
|
124
113
|
|
|
125
|
-
| Method
|
|
126
|
-
|
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
114
|
+
| Method | Description |
|
|
115
|
+
| ------------------------------------------------- | ----------------------------------------------- |
|
|
116
|
+
| `HumanAttestation.verify_claim(hap_id, issuer)` | Fetch and verify a claim, returns claim or nil |
|
|
117
|
+
| `HumanAttestation.fetch_claim(hap_id, issuer)` | Fetch raw verification response from VA |
|
|
118
|
+
| `HumanAttestation.verify_signature(jws, issuer)` | Verify JWS signature against VA's public keys |
|
|
119
|
+
| `HumanAttestation.fetch_public_keys(issuer)` | Fetch VA's public keys from well-known endpoint |
|
|
120
|
+
| `HumanAttestation.valid_id?(id)` | Check if string matches HAP ID format |
|
|
121
|
+
| `HumanAttestation.extract_id_from_url(url)` | Extract HAP ID from verification URL |
|
|
122
|
+
| `HumanAttestation.claim_expired?(claim)` | Check if claim has passed expiration |
|
|
123
|
+
| `HumanAttestation.claim_for_recipient?(claim, domain)` | Check if claim targets specific recipient |
|
|
135
124
|
|
|
136
125
|
### Signing Functions (For VAs)
|
|
137
126
|
|
|
138
|
-
| Method
|
|
139
|
-
|
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `Hap.create_recipient_commitment_claim(...)` | Create recipient_commitment claim |
|
|
146
|
-
|
|
147
|
-
### Constants
|
|
148
|
-
|
|
149
|
-
```ruby
|
|
150
|
-
# Claim types
|
|
151
|
-
Hap::CLAIM_TYPE_HUMAN_EFFORT # "human_effort"
|
|
152
|
-
Hap::CLAIM_TYPE_RECIPIENT_COMMITMENT # "recipient_commitment"
|
|
153
|
-
|
|
154
|
-
# Verification methods
|
|
155
|
-
Hap::METHOD_PHYSICAL_MAIL # "physical_mail"
|
|
156
|
-
Hap::METHOD_VIDEO_INTERVIEW # "video_interview"
|
|
157
|
-
Hap::METHOD_PAID_ASSESSMENT # "paid_assessment"
|
|
158
|
-
Hap::METHOD_REFERRAL # "referral"
|
|
159
|
-
|
|
160
|
-
# Commitment levels
|
|
161
|
-
Hap::COMMITMENT_REVIEW_VERIFIED # "review_verified"
|
|
162
|
-
Hap::COMMITMENT_PRIORITIZE_VERIFIED # "prioritize_verified"
|
|
163
|
-
Hap::COMMITMENT_RESPOND_VERIFIED # "respond_verified"
|
|
164
|
-
```
|
|
127
|
+
| Method | Description |
|
|
128
|
+
| ----------------------------------------------------- | ---------------------------------------- |
|
|
129
|
+
| `HumanAttestation.generate_key_pair` | Generate Ed25519 key pair |
|
|
130
|
+
| `HumanAttestation.export_public_key_jwk(key, kid)` | Export public key as JWK |
|
|
131
|
+
| `HumanAttestation.sign_claim(claim, private_key, kid:)` | Sign a claim, returns JWS |
|
|
132
|
+
| `HumanAttestation.generate_id` | Generate cryptographically secure HAP ID |
|
|
133
|
+
| `HumanAttestation.create_claim(...)` | Create claim with defaults |
|
|
165
134
|
|
|
166
135
|
## Requirements
|
|
167
136
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ed25519"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "cgi"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module HumanAttestation
|
|
9
|
+
# HAP Compact Format utilities for space-efficient serialization
|
|
10
|
+
#
|
|
11
|
+
# Format: HAP{version}.{id}.{method}.{to_name}.{to_domain}.{at}.{exp}.{iss}.{signature}
|
|
12
|
+
#
|
|
13
|
+
# Note: Effort dimensions (cost, time, physical, energy) are NOT included in compact format.
|
|
14
|
+
# Compact is for QR codes - minimal representation. Full claims in JWS include all dimensions.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# HAP1.hap_abc123xyz456.ba_priority_mail.Acme%20Corp.acme%2Ecom.1706169600.1769241600.ballista%2Ejobs.MEUCIQDx...
|
|
18
|
+
module Compact
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Encodes a field for compact format (URL-encode + encode dots)
|
|
22
|
+
#
|
|
23
|
+
# @param value [String] The value to encode
|
|
24
|
+
# @return [String] Encoded value
|
|
25
|
+
def encode_compact_field(value)
|
|
26
|
+
CGI.escape(value.to_s).gsub(".", "%2E")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Decodes a compact format field
|
|
30
|
+
#
|
|
31
|
+
# @param value [String] The encoded value
|
|
32
|
+
# @return [String] Decoded value
|
|
33
|
+
def decode_compact_field(value)
|
|
34
|
+
CGI.unescape(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Base64url encode without padding
|
|
38
|
+
#
|
|
39
|
+
# @param data [String] Binary data to encode
|
|
40
|
+
# @return [String] Base64url encoded string
|
|
41
|
+
def base64url_encode(data)
|
|
42
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Base64url decode
|
|
46
|
+
#
|
|
47
|
+
# @param data [String] Base64url encoded string
|
|
48
|
+
# @return [String] Decoded binary data
|
|
49
|
+
def base64url_decode(data)
|
|
50
|
+
Base64.urlsafe_decode64(data)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Convert ISO 8601 timestamp to Unix epoch seconds
|
|
54
|
+
#
|
|
55
|
+
# @param iso [String] ISO 8601 timestamp
|
|
56
|
+
# @return [Integer] Unix timestamp
|
|
57
|
+
def iso_to_unix(iso)
|
|
58
|
+
Time.parse(iso).to_i
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Convert Unix epoch seconds to ISO 8601 timestamp
|
|
62
|
+
#
|
|
63
|
+
# @param unix [Integer] Unix timestamp
|
|
64
|
+
# @return [String] ISO 8601 timestamp
|
|
65
|
+
def unix_to_iso(unix)
|
|
66
|
+
Time.at(unix).utc.iso8601
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Encodes a HAP claim and signature into compact format (9 fields)
|
|
70
|
+
#
|
|
71
|
+
# @param claim [Hash] The claim to encode
|
|
72
|
+
# @param signature [String] The Ed25519 signature bytes (64 bytes)
|
|
73
|
+
# @return [String] Compact format string
|
|
74
|
+
def encode_compact(claim, signature)
|
|
75
|
+
to = claim[:to] || {}
|
|
76
|
+
name = to[:name] || ""
|
|
77
|
+
domain = to[:domain] || ""
|
|
78
|
+
|
|
79
|
+
at_unix = iso_to_unix(claim[:at])
|
|
80
|
+
exp_unix = claim[:exp] ? iso_to_unix(claim[:exp]) : 0
|
|
81
|
+
|
|
82
|
+
[
|
|
83
|
+
"HAP#{COMPACT_VERSION}",
|
|
84
|
+
claim[:id],
|
|
85
|
+
claim[:method] || "",
|
|
86
|
+
encode_compact_field(name),
|
|
87
|
+
encode_compact_field(domain),
|
|
88
|
+
at_unix.to_s,
|
|
89
|
+
exp_unix.to_s,
|
|
90
|
+
encode_compact_field(claim[:iss]),
|
|
91
|
+
base64url_encode(signature)
|
|
92
|
+
].join(".")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Decodes a compact format string into claim and signature
|
|
96
|
+
#
|
|
97
|
+
# @param compact [String] The compact format string
|
|
98
|
+
# @return [Hash] { claim: Hash, signature: String }
|
|
99
|
+
# @raise [ArgumentError] If format is invalid
|
|
100
|
+
def decode_compact(compact)
|
|
101
|
+
raise ArgumentError, "Invalid HAP Compact format" unless valid_compact?(compact)
|
|
102
|
+
|
|
103
|
+
parts = compact.split(".")
|
|
104
|
+
raise ArgumentError, "Invalid HAP Compact format: expected 9 fields" unless parts.length == 9
|
|
105
|
+
|
|
106
|
+
version, hap_id, method, encoded_name, encoded_domain, at_unix_str, exp_unix_str, encoded_iss, sig_b64 = parts
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Unsupported compact version: #{version}" unless version == "HAP#{COMPACT_VERSION}"
|
|
109
|
+
|
|
110
|
+
name = decode_compact_field(encoded_name)
|
|
111
|
+
domain = decode_compact_field(encoded_domain)
|
|
112
|
+
iss = decode_compact_field(encoded_iss)
|
|
113
|
+
at_unix = at_unix_str.to_i
|
|
114
|
+
exp_unix = exp_unix_str.to_i
|
|
115
|
+
signature = base64url_decode(sig_b64)
|
|
116
|
+
|
|
117
|
+
at = unix_to_iso(at_unix)
|
|
118
|
+
exp = exp_unix != 0 ? unix_to_iso(exp_unix) : nil
|
|
119
|
+
|
|
120
|
+
claim = {
|
|
121
|
+
v: VERSION_PROTOCOL,
|
|
122
|
+
id: hap_id,
|
|
123
|
+
method: method,
|
|
124
|
+
description: "", # Not included in compact format
|
|
125
|
+
to: { name: name },
|
|
126
|
+
at: at,
|
|
127
|
+
iss: iss
|
|
128
|
+
}
|
|
129
|
+
claim[:to][:domain] = domain unless domain.empty?
|
|
130
|
+
claim[:exp] = exp if exp
|
|
131
|
+
|
|
132
|
+
{ claim: claim, signature: signature }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Validates if a string is a valid HAP Compact format
|
|
136
|
+
#
|
|
137
|
+
# @param compact [String] The string to validate
|
|
138
|
+
# @return [Boolean] true if valid compact format
|
|
139
|
+
def valid_compact?(compact)
|
|
140
|
+
COMPACT_REGEX.match?(compact.to_s)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Builds the compact payload (everything before the signature)
|
|
144
|
+
# This is what gets signed.
|
|
145
|
+
#
|
|
146
|
+
# @param claim [Hash] The claim
|
|
147
|
+
# @return [String] Compact payload string (8 fields)
|
|
148
|
+
def build_compact_payload(claim)
|
|
149
|
+
to = claim[:to] || {}
|
|
150
|
+
name = to[:name] || ""
|
|
151
|
+
domain = to[:domain] || ""
|
|
152
|
+
|
|
153
|
+
at_unix = iso_to_unix(claim[:at])
|
|
154
|
+
exp_unix = claim[:exp] ? iso_to_unix(claim[:exp]) : 0
|
|
155
|
+
|
|
156
|
+
[
|
|
157
|
+
"HAP#{COMPACT_VERSION}",
|
|
158
|
+
claim[:id],
|
|
159
|
+
claim[:method] || "",
|
|
160
|
+
encode_compact_field(name),
|
|
161
|
+
encode_compact_field(domain),
|
|
162
|
+
at_unix.to_s,
|
|
163
|
+
exp_unix.to_s,
|
|
164
|
+
encode_compact_field(claim[:iss])
|
|
165
|
+
].join(".")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Signs a claim and returns it in compact format
|
|
169
|
+
#
|
|
170
|
+
# @param claim [Hash] The claim to sign
|
|
171
|
+
# @param private_key [Ed25519::SigningKey] The Ed25519 private key
|
|
172
|
+
# @return [String] Signed compact format string
|
|
173
|
+
def sign_compact(claim, private_key)
|
|
174
|
+
payload = build_compact_payload(claim)
|
|
175
|
+
signature = private_key.sign(payload)
|
|
176
|
+
"#{payload}.#{base64url_encode(signature)}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Verifies a compact format string using provided public keys
|
|
180
|
+
#
|
|
181
|
+
# @param compact [String] The compact format string
|
|
182
|
+
# @param public_keys [Array<Hash>] Array of JWK public keys to try
|
|
183
|
+
# @return [Hash] { valid: Boolean, claim: Hash or nil, error: String or nil }
|
|
184
|
+
def verify_compact(compact, public_keys)
|
|
185
|
+
return { valid: false, error: "Invalid compact format" } unless valid_compact?(compact)
|
|
186
|
+
|
|
187
|
+
# Split to get payload and signature
|
|
188
|
+
last_dot = compact.rindex(".")
|
|
189
|
+
payload = compact[0...last_dot]
|
|
190
|
+
sig_b64 = compact[(last_dot + 1)..]
|
|
191
|
+
signature = base64url_decode(sig_b64)
|
|
192
|
+
|
|
193
|
+
# Try each public key
|
|
194
|
+
public_keys.each do |jwk|
|
|
195
|
+
begin
|
|
196
|
+
x_bytes = base64url_decode(jwk[:x] || jwk["x"])
|
|
197
|
+
verify_key = Ed25519::VerifyKey.new(x_bytes)
|
|
198
|
+
|
|
199
|
+
# Verify signature
|
|
200
|
+
verify_key.verify(signature, payload)
|
|
201
|
+
|
|
202
|
+
# Signature is valid
|
|
203
|
+
decoded = decode_compact(compact)
|
|
204
|
+
return { valid: true, claim: decoded[:claim] }
|
|
205
|
+
rescue Ed25519::VerifyError, StandardError
|
|
206
|
+
# Try next key
|
|
207
|
+
next
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
{ valid: false, error: "Signature verification failed" }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Generates a verification URL with embedded compact claim
|
|
215
|
+
#
|
|
216
|
+
# @param base_url [String] Base verification URL (e.g., "https://ballista.jobs/v")
|
|
217
|
+
# @param compact [String] The compact format string
|
|
218
|
+
# @return [String] URL with compact claim in query parameter
|
|
219
|
+
def generate_verification_url(base_url, compact)
|
|
220
|
+
"#{base_url}?c=#{CGI.escape(compact)}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Extracts compact claim from a verification URL
|
|
224
|
+
#
|
|
225
|
+
# @param url [String] The verification URL
|
|
226
|
+
# @return [String, nil] Compact string or nil if not found
|
|
227
|
+
def extract_compact_from_url(url)
|
|
228
|
+
uri = URI.parse(url)
|
|
229
|
+
params = URI.decode_www_form(uri.query || "").to_h
|
|
230
|
+
compact = params["c"]
|
|
231
|
+
return compact if compact && valid_compact?(compact)
|
|
232
|
+
|
|
233
|
+
nil
|
|
234
|
+
rescue StandardError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -5,21 +5,47 @@ require "json"
|
|
|
5
5
|
require "base64"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "time"
|
|
8
|
+
require "digest"
|
|
8
9
|
|
|
9
|
-
module
|
|
10
|
+
module HumanAttestation
|
|
10
11
|
# Signing functions for HAP claims (for Verification Authorities)
|
|
11
12
|
module Sign
|
|
12
13
|
# Characters used for HAP ID generation
|
|
13
|
-
|
|
14
|
+
ID_CHARS = ("A".."Z").to_a + ("a".."z").to_a + ("0".."9").to_a
|
|
14
15
|
|
|
15
16
|
# Generates a cryptographically secure random HAP ID
|
|
16
17
|
#
|
|
17
18
|
# @return [String] A HAP ID in the format hap_[a-zA-Z0-9]{12}
|
|
18
|
-
def
|
|
19
|
-
suffix = Array.new(12) {
|
|
19
|
+
def generate_id
|
|
20
|
+
suffix = Array.new(12) { ID_CHARS[SecureRandom.random_number(ID_CHARS.length)] }.join
|
|
20
21
|
"hap_#{suffix}"
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# Generates a test HAP ID (for previews and development)
|
|
25
|
+
#
|
|
26
|
+
# @return [String] A test HAP ID in the format hap_test_[a-zA-Z0-9]{8}
|
|
27
|
+
def generate_test_id
|
|
28
|
+
suffix = Array.new(8) { ID_CHARS[SecureRandom.random_number(ID_CHARS.length)] }.join
|
|
29
|
+
"hap_test_#{suffix}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Checks if a HAP ID is a test ID
|
|
33
|
+
#
|
|
34
|
+
# @param id [String] The HAP ID to check
|
|
35
|
+
# @return [Boolean] true if the ID is a test ID
|
|
36
|
+
def test_id?(id)
|
|
37
|
+
TEST_ID_REGEX.match?(id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Computes SHA-256 hash of content with prefix
|
|
41
|
+
#
|
|
42
|
+
# @param content [String] The content to hash
|
|
43
|
+
# @return [String] Hash string in format "sha256:xxxxx"
|
|
44
|
+
def hash_content(content)
|
|
45
|
+
hash = Digest::SHA256.hexdigest(content)
|
|
46
|
+
"sha256:#{hash}"
|
|
47
|
+
end
|
|
48
|
+
|
|
23
49
|
# Generates a new Ed25519 key pair for signing HAP claims
|
|
24
50
|
#
|
|
25
51
|
# @return [Array<Ed25519::SigningKey, Ed25519::VerifyKey>] Private and public keys
|
|
@@ -71,23 +97,28 @@ module Hap
|
|
|
71
97
|
"#{signing_input}.#{signature_b64}"
|
|
72
98
|
end
|
|
73
99
|
|
|
74
|
-
# Creates a complete
|
|
100
|
+
# Creates a complete HAP claim with all required fields
|
|
75
101
|
#
|
|
76
|
-
# @param method [String]
|
|
102
|
+
# @param method [String] VA-specific verification method identifier
|
|
103
|
+
# @param description [String] Human-readable description of the effort
|
|
77
104
|
# @param recipient_name [String] Recipient name
|
|
78
105
|
# @param issuer [String] VA's domain
|
|
79
106
|
# @param domain [String, nil] Recipient domain (optional)
|
|
80
107
|
# @param tier [String, nil] Service tier (optional)
|
|
81
108
|
# @param expires_in_days [Integer, nil] Days until expiration (optional)
|
|
82
|
-
# @
|
|
83
|
-
|
|
109
|
+
# @param cost [Hash, nil] Monetary cost { amount: Integer, currency: String } (optional)
|
|
110
|
+
# @param time [Integer, nil] Time in seconds (optional)
|
|
111
|
+
# @param physical [Boolean, nil] Whether physical atoms involved (optional)
|
|
112
|
+
# @param energy [Integer, nil] Energy in kilocalories (optional)
|
|
113
|
+
# @return [Hash] A complete HAP claim
|
|
114
|
+
def create_claim(method:, description:, recipient_name:, issuer:, domain: nil, tier: nil, expires_in_days: nil, cost: nil, time: nil, physical: nil, energy: nil)
|
|
84
115
|
now = Time.now.utc
|
|
85
116
|
|
|
86
117
|
claim = {
|
|
87
118
|
v: VERSION_PROTOCOL,
|
|
88
|
-
id:
|
|
89
|
-
type: CLAIM_TYPE_HUMAN_EFFORT,
|
|
119
|
+
id: generate_id,
|
|
90
120
|
method: method,
|
|
121
|
+
description: description,
|
|
91
122
|
to: { name: recipient_name },
|
|
92
123
|
at: now.iso8601,
|
|
93
124
|
iss: issuer
|
|
@@ -101,36 +132,11 @@ module Hap
|
|
|
101
132
|
claim[:exp] = exp.iso8601
|
|
102
133
|
end
|
|
103
134
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# @param recipient_name [String] Recipient's name
|
|
110
|
-
# @param commitment [String] Commitment level (e.g., "review_verified")
|
|
111
|
-
# @param issuer [String] VA's domain
|
|
112
|
-
# @param recipient_domain [String, nil] Recipient's domain (optional)
|
|
113
|
-
# @param expires_in_days [Integer, nil] Days until expiration (optional)
|
|
114
|
-
# @return [Hash] A complete recipient commitment claim
|
|
115
|
-
def create_recipient_commitment_claim(recipient_name:, commitment:, issuer:, recipient_domain: nil, expires_in_days: nil)
|
|
116
|
-
now = Time.now.utc
|
|
117
|
-
|
|
118
|
-
claim = {
|
|
119
|
-
v: VERSION_PROTOCOL,
|
|
120
|
-
id: generate_hap_id,
|
|
121
|
-
type: CLAIM_TYPE_RECIPIENT_COMMITMENT,
|
|
122
|
-
recipient: { name: recipient_name },
|
|
123
|
-
commitment: commitment,
|
|
124
|
-
at: now.iso8601,
|
|
125
|
-
iss: issuer
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
claim[:recipient][:domain] = recipient_domain if recipient_domain
|
|
129
|
-
|
|
130
|
-
if expires_in_days
|
|
131
|
-
exp = now + (expires_in_days * 24 * 60 * 60)
|
|
132
|
-
claim[:exp] = exp.iso8601
|
|
133
|
-
end
|
|
135
|
+
# Add effort dimensions if provided
|
|
136
|
+
claim[:cost] = cost if cost
|
|
137
|
+
claim[:time] = time if time
|
|
138
|
+
claim[:physical] = physical unless physical.nil?
|
|
139
|
+
claim[:energy] = energy if energy
|
|
134
140
|
|
|
135
141
|
claim
|
|
136
142
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HumanAttestation
|
|
4
|
+
# HAP Compact format version
|
|
5
|
+
COMPACT_VERSION = "1"
|
|
6
|
+
|
|
7
|
+
# Test HAP ID format regex
|
|
8
|
+
TEST_ID_REGEX = /\Ahap_test_[a-zA-Z0-9]{8}\z/
|
|
9
|
+
|
|
10
|
+
# HAP Compact format regex (9 fields, no type)
|
|
11
|
+
COMPACT_REGEX = /\AHAP1\.hap_[a-zA-Z0-9_]+\.[^.]+\.[^.]+\.[^.]*\.\d+\.\d+\.[^.]+\.[A-Za-z0-9_-]+\z/
|
|
12
|
+
|
|
13
|
+
# Revocation reasons
|
|
14
|
+
REVOCATION_FRAUD = "fraud"
|
|
15
|
+
REVOCATION_ERROR = "error"
|
|
16
|
+
REVOCATION_LEGAL = "legal"
|
|
17
|
+
REVOCATION_USER_REQUEST = "user_request"
|
|
18
|
+
end
|
|
@@ -8,7 +8,7 @@ require "base64"
|
|
|
8
8
|
require "uri"
|
|
9
9
|
require "time"
|
|
10
10
|
|
|
11
|
-
module
|
|
11
|
+
module HumanAttestation
|
|
12
12
|
# Verification functions for HAP claims
|
|
13
13
|
module Verify
|
|
14
14
|
# Default timeout for HTTP requests (seconds)
|
|
@@ -18,8 +18,8 @@ module Hap
|
|
|
18
18
|
#
|
|
19
19
|
# @param id [String] The HAP ID to validate
|
|
20
20
|
# @return [Boolean] true if the ID matches the format hap_[a-zA-Z0-9]{12}
|
|
21
|
-
def
|
|
22
|
-
|
|
21
|
+
def valid_id?(id)
|
|
22
|
+
ID_REGEX.match?(id.to_s)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# Fetches the public keys from a VA's well-known endpoint
|
|
@@ -47,7 +47,7 @@ module Hap
|
|
|
47
47
|
# @param timeout [Integer] Request timeout in seconds
|
|
48
48
|
# @return [Hash] The verification response from the VA
|
|
49
49
|
def fetch_claim(hap_id, issuer_domain, timeout: DEFAULT_TIMEOUT)
|
|
50
|
-
return { valid: false, error: "invalid_format" } unless
|
|
50
|
+
return { valid: false, error: "invalid_format" } unless valid_id?(hap_id)
|
|
51
51
|
|
|
52
52
|
url = "https://#{issuer_domain}/api/v1/verify/#{hap_id}"
|
|
53
53
|
|
|
@@ -115,7 +115,7 @@ module Hap
|
|
|
115
115
|
# @param verify_sig [Boolean] Whether to verify the cryptographic signature
|
|
116
116
|
# @param timeout [Integer] Request timeout in seconds
|
|
117
117
|
# @return [Hash, nil] The claim if valid, nil if not found or invalid
|
|
118
|
-
def
|
|
118
|
+
def verify_claim(hap_id, issuer_domain, verify_sig: true, timeout: DEFAULT_TIMEOUT)
|
|
119
119
|
response = fetch_claim(hap_id, issuer_domain, timeout: timeout)
|
|
120
120
|
|
|
121
121
|
return nil unless response[:valid]
|
|
@@ -132,12 +132,12 @@ module Hap
|
|
|
132
132
|
#
|
|
133
133
|
# @param url [String] The verification URL
|
|
134
134
|
# @return [String, nil] The HAP ID or nil if not found
|
|
135
|
-
def
|
|
135
|
+
def extract_id_from_url(url)
|
|
136
136
|
uri = URI.parse(url)
|
|
137
137
|
parts = uri.path.split("/")
|
|
138
138
|
last_part = parts.last
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
valid_id?(last_part) ? last_part : nil
|
|
141
141
|
rescue URI::InvalidURIError
|
|
142
142
|
nil
|
|
143
143
|
end
|
|
@@ -161,14 +161,7 @@ module Hap
|
|
|
161
161
|
# @param recipient_domain [String] The expected recipient domain
|
|
162
162
|
# @return [Boolean] true if the claim's target domain matches
|
|
163
163
|
def claim_for_recipient?(claim, recipient_domain)
|
|
164
|
-
|
|
165
|
-
when CLAIM_TYPE_HUMAN_EFFORT
|
|
166
|
-
claim.dig(:to, :domain) == recipient_domain
|
|
167
|
-
when CLAIM_TYPE_RECIPIENT_COMMITMENT
|
|
168
|
-
claim.dig(:recipient, :domain) == recipient_domain
|
|
169
|
-
else
|
|
170
|
-
false
|
|
171
|
-
end
|
|
164
|
+
claim.dig(:to, :domain) == recipient_domain
|
|
172
165
|
end
|
|
173
166
|
end
|
|
174
167
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "human_attestation/version"
|
|
4
|
+
require_relative "human_attestation/types"
|
|
5
|
+
require_relative "human_attestation/verify"
|
|
6
|
+
require_relative "human_attestation/sign"
|
|
7
|
+
require_relative "human_attestation/compact"
|
|
8
|
+
|
|
9
|
+
# HAP (Human Attestation Protocol) SDK for Ruby
|
|
10
|
+
#
|
|
11
|
+
# HAP is an open standard for verified human effort. It enables Verification
|
|
12
|
+
# Authorities (VAs) to cryptographically attest that a sender took deliberate,
|
|
13
|
+
# costly action when communicating with a recipient.
|
|
14
|
+
#
|
|
15
|
+
# @example Verifying a claim (for recipients)
|
|
16
|
+
# claim = HumanAttestation.verify_claim("hap_abc123xyz456", "ballista.jobs")
|
|
17
|
+
# if claim && !HumanAttestation.claim_expired?(claim)
|
|
18
|
+
# puts "Verified application to #{claim[:to][:name]}"
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Signing a claim (for VAs)
|
|
22
|
+
# private_key, public_key = HumanAttestation.generate_key_pair
|
|
23
|
+
# claim = HumanAttestation.create_claim(
|
|
24
|
+
# method: "ba_priority_mail",
|
|
25
|
+
# description: "Priority mail packet with handwritten cover letter",
|
|
26
|
+
# recipient_name: "Acme Corp",
|
|
27
|
+
# domain: "acme.com",
|
|
28
|
+
# issuer: "my-va.com",
|
|
29
|
+
# cost: { amount: 1500, currency: "USD" },
|
|
30
|
+
# time: 1800,
|
|
31
|
+
# physical: true
|
|
32
|
+
# )
|
|
33
|
+
# jws = HumanAttestation.sign_claim(claim, private_key, kid: "key_001")
|
|
34
|
+
#
|
|
35
|
+
module HumanAttestation
|
|
36
|
+
class Error < StandardError; end
|
|
37
|
+
class VerificationError < Error; end
|
|
38
|
+
class SigningError < Error; end
|
|
39
|
+
|
|
40
|
+
# Protocol version
|
|
41
|
+
VERSION_PROTOCOL = "0.1"
|
|
42
|
+
|
|
43
|
+
# HAP ID format regex
|
|
44
|
+
ID_REGEX = /\Ahap_[a-zA-Z0-9]{12}\z/
|
|
45
|
+
|
|
46
|
+
# Extend self to allow calling methods directly on the module
|
|
47
|
+
extend Verify
|
|
48
|
+
extend Sign
|
|
49
|
+
extend Compact
|
|
50
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: human-attestation
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- BlueScroll Inc.
|
|
@@ -90,11 +90,12 @@ extra_rdoc_files: []
|
|
|
90
90
|
files:
|
|
91
91
|
- LICENSE
|
|
92
92
|
- README.md
|
|
93
|
-
- lib/
|
|
94
|
-
- lib/
|
|
95
|
-
- lib/
|
|
96
|
-
- lib/
|
|
97
|
-
- lib/
|
|
93
|
+
- lib/human_attestation.rb
|
|
94
|
+
- lib/human_attestation/compact.rb
|
|
95
|
+
- lib/human_attestation/sign.rb
|
|
96
|
+
- lib/human_attestation/types.rb
|
|
97
|
+
- lib/human_attestation/verify.rb
|
|
98
|
+
- lib/human_attestation/version.rb
|
|
98
99
|
homepage: https://github.com/Blue-Scroll/hap
|
|
99
100
|
licenses:
|
|
100
101
|
- Apache-2.0
|
data/lib/hap/types.rb
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Hap
|
|
4
|
-
# Claim types
|
|
5
|
-
CLAIM_TYPE_HUMAN_EFFORT = "human_effort"
|
|
6
|
-
CLAIM_TYPE_RECIPIENT_COMMITMENT = "recipient_commitment"
|
|
7
|
-
|
|
8
|
-
# Core verification methods
|
|
9
|
-
METHOD_PHYSICAL_MAIL = "physical_mail"
|
|
10
|
-
METHOD_VIDEO_INTERVIEW = "video_interview"
|
|
11
|
-
METHOD_PAID_ASSESSMENT = "paid_assessment"
|
|
12
|
-
METHOD_REFERRAL = "referral"
|
|
13
|
-
|
|
14
|
-
# Commitment levels
|
|
15
|
-
COMMITMENT_REVIEW_VERIFIED = "review_verified"
|
|
16
|
-
COMMITMENT_PRIORITIZE_VERIFIED = "prioritize_verified"
|
|
17
|
-
COMMITMENT_RESPOND_VERIFIED = "respond_verified"
|
|
18
|
-
|
|
19
|
-
# Revocation reasons
|
|
20
|
-
REVOCATION_FRAUD = "fraud"
|
|
21
|
-
REVOCATION_ERROR = "error"
|
|
22
|
-
REVOCATION_LEGAL = "legal"
|
|
23
|
-
REVOCATION_USER_REQUEST = "user_request"
|
|
24
|
-
end
|
data/lib/hap/version.rb
DELETED
data/lib/hap.rb
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "hap/version"
|
|
4
|
-
require_relative "hap/types"
|
|
5
|
-
require_relative "hap/verify"
|
|
6
|
-
require_relative "hap/sign"
|
|
7
|
-
|
|
8
|
-
# HAP (Human Attestation Protocol) SDK for Ruby
|
|
9
|
-
#
|
|
10
|
-
# HAP is an open standard for verified human effort. It enables Verification
|
|
11
|
-
# Authorities (VAs) to cryptographically attest that a sender took deliberate,
|
|
12
|
-
# costly action when communicating with a recipient.
|
|
13
|
-
#
|
|
14
|
-
# @example Verifying a claim (for recipients)
|
|
15
|
-
# claim = Hap.verify_hap_claim("hap_abc123xyz456", "ballista.jobs")
|
|
16
|
-
# if claim && !Hap.claim_expired?(claim)
|
|
17
|
-
# puts "Verified application to #{claim[:to][:name]}"
|
|
18
|
-
# end
|
|
19
|
-
#
|
|
20
|
-
# @example Signing a claim (for VAs)
|
|
21
|
-
# private_key, public_key = Hap.generate_key_pair
|
|
22
|
-
# claim = Hap.create_human_effort_claim(
|
|
23
|
-
# method: "physical_mail",
|
|
24
|
-
# recipient_name: "Acme Corp",
|
|
25
|
-
# domain: "acme.com",
|
|
26
|
-
# issuer: "my-va.com"
|
|
27
|
-
# )
|
|
28
|
-
# jws = Hap.sign_claim(claim, private_key, kid: "key_001")
|
|
29
|
-
#
|
|
30
|
-
module Hap
|
|
31
|
-
class Error < StandardError; end
|
|
32
|
-
class VerificationError < Error; end
|
|
33
|
-
class SigningError < Error; end
|
|
34
|
-
|
|
35
|
-
# Protocol version
|
|
36
|
-
VERSION_PROTOCOL = "0.1"
|
|
37
|
-
|
|
38
|
-
# HAP ID format regex
|
|
39
|
-
HAP_ID_REGEX = /\Ahap_[a-zA-Z0-9]{12}\z/
|
|
40
|
-
|
|
41
|
-
# Extend self to allow calling methods directly on the module
|
|
42
|
-
extend Verify
|
|
43
|
-
extend Sign
|
|
44
|
-
end
|