kiosk-server 0.5.20 → 0.6.1

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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +63 -41
  4. data/app/controllers/kiosk/server/auth_controller.rb +18 -62
  5. data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
  6. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  7. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  8. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
  9. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  10. data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
  11. data/app/models/kiosk/checkout_mandate.rb +22 -0
  12. data/app/models/kiosk/settlement.rb +1 -1
  13. data/lib/generators/kiosk/install/install_generator.rb +0 -5
  14. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
  15. data/lib/kiosk/payable.rb +19 -25
  16. data/lib/kiosk/server/account_binding.rb +19 -58
  17. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  18. data/lib/kiosk/server/agent_rotation.rb +62 -0
  19. data/lib/kiosk/server/assistant_key.rb +86 -0
  20. data/lib/kiosk/server/auth_toll.rb +1 -1
  21. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  22. data/lib/kiosk/server/checkout.rb +57 -0
  23. data/lib/kiosk/server/client.rb +61 -0
  24. data/lib/kiosk/server/client_assertion.rb +153 -0
  25. data/lib/kiosk/server/client_registration.rb +94 -0
  26. data/lib/kiosk/server/configuration_extension.rb +56 -44
  27. data/lib/kiosk/server/device_code_grant.rb +38 -69
  28. data/lib/kiosk/server/dpop.rb +121 -0
  29. data/lib/kiosk/server/eddsa.rb +38 -0
  30. data/lib/kiosk/server/engine.rb +14 -11
  31. data/lib/kiosk/server/errors.rb +7 -0
  32. data/lib/kiosk/server/events_connection.rb +15 -5
  33. data/lib/kiosk/server/executor/repeat.rb +13 -12
  34. data/lib/kiosk/server/executor/spending_cap.rb +25 -26
  35. data/lib/kiosk/server/executor.rb +82 -101
  36. data/lib/kiosk/server/identity_resolution.rb +12 -3
  37. data/lib/kiosk/server/jwk.rb +83 -0
  38. data/lib/kiosk/server/jwks.rb +6 -0
  39. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  40. data/lib/kiosk/server/kyc_verifier.rb +2 -2
  41. data/lib/kiosk/server/link_code.rb +9 -18
  42. data/lib/kiosk/server/linked_assistants.rb +1 -1
  43. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  44. data/lib/kiosk/server/oauth_answers.rb +30 -0
  45. data/lib/kiosk/server/oauth_error.rb +30 -0
  46. data/lib/kiosk/server/open_api.rb +22 -20
  47. data/lib/kiosk/server/payment_claim.rb +44 -75
  48. data/lib/kiosk/server/receipts.rb +29 -0
  49. data/lib/kiosk/server/request_validation.rb +1 -2
  50. data/lib/kiosk/server/revocation_stores.rb +8 -0
  51. data/lib/kiosk/server/schema_definitions.rb +33 -64
  52. data/lib/kiosk/server/schemas/auth.schema.json +368 -57
  53. data/lib/kiosk/server/schemas/binding.schema.json +23 -58
  54. data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
  55. data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
  56. data/lib/kiosk/server/sd_jwt.rb +104 -0
  57. data/lib/kiosk/server/signing_key.rb +18 -52
  58. data/lib/kiosk/server/verb_wire.rb +1 -1
  59. data/lib/kiosk/server/version.rb +1 -1
  60. data/lib/kiosk/server/well_known.rb +268 -204
  61. data/lib/kiosk/server/wire_rendering.rb +1 -1
  62. data/lib/kiosk/server.rb +2 -0
  63. data/lib/kiosk/skill_link.rb +6 -2
  64. data/listen.py +38 -8
  65. metadata +26 -17
  66. data/app/models/kiosk/cart_mandate.rb +0 -21
  67. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  68. data/lib/kiosk/server/agent_login.rb +0 -49
  69. data/lib/kiosk/server/agent_registration.rb +0 -88
  70. data/lib/kiosk/server/auth_challenge.rb +0 -33
  71. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  72. data/lib/kiosk/server/pop_verifier.rb +0 -87
@@ -8,106 +8,49 @@
8
8
  "attestation": {
9
9
  "type": "object",
10
10
  "description": "Signed by the KYC provider, verified with its configured public key.",
11
- "required": [
12
- "sub",
13
- "level",
14
- "iss",
15
- "aud",
16
- "exp"
17
- ],
11
+ "required": ["sub", "level", "iss", "aud", "exp"],
18
12
  "properties": {
19
- "sub": {
20
- "type": "string",
21
- "description": "MUST equal the authenticated user_id."
22
- },
23
- "level": {
24
- "const": "verified",
25
- "description": "Binary; anything other than \"verified\" is rejected."
26
- },
27
- "iss": {
28
- "type": "string",
29
- "description": "MUST equal the provider-configured KYC issuer."
30
- },
31
- "aud": {
32
- "type": "string",
33
- "description": "OPERATOR-BINDING. The operator the attestation was minted for. MUST equal the operator's configured kyc_audience (its origin, or a stable handle it declares); the operator rejects any other aud. This stops a claim minted for operator A being replayed to operator B."
34
- },
35
- "iat": {
36
- "type": "integer",
37
- "description": "Issued-at timestamp. Not enforced by the verifier."
38
- },
39
- "exp": {
40
- "type": "integer",
41
- "description": "One year after iat. An expired attestation is rejected."
42
- },
43
- "attributes": {
44
- "$ref": "#/$defs/attributes"
45
- }
13
+ "sub": { "type": "string", "description": "MUST equal the authenticated user_id." },
14
+ "level": { "const": "verified", "description": "Binary; anything other than \"verified\" is rejected." },
15
+ "iss": { "type": "string", "description": "MUST equal the provider-configured KYC issuer." },
16
+ "aud": { "type": "string", "description": "OPERATOR-BINDING. The operator the attestation was minted for. MUST equal the operator's configured kyc_audience (its origin, or a stable handle it declares); the operator rejects any other aud. This stops a claim minted for operator A being replayed to operator B." },
17
+ "iat": { "type": "integer", "description": "Issued-at timestamp. Not enforced by the verifier." },
18
+ "exp": { "type": "integer", "description": "One year after iat. An expired attestation is rejected." },
19
+ "attributes": { "$ref": "#/$defs/attributes" }
46
20
  }
47
21
  },
48
22
  "attributes": {
49
23
  "type": "object",
50
24
  "description": "Optional named boolean attributes that the KYC provider vouches for, e.g. age_over_18. Only a value that is literally true counts; an action MAY require named attributes and refuse with kyc_required (403).",
51
- "additionalProperties": {
52
- "type": "boolean"
53
- }
25
+ "additionalProperties": { "type": "boolean" }
54
26
  },
55
27
  "request": {
56
28
  "type": "object",
57
- "required": [
58
- "kyc_jws"
59
- ],
29
+ "required": ["kyc_jws"],
60
30
  "properties": {
61
- "kyc_jws": {
62
- "type": "string",
63
- "description": "The attestation as a compact RS256 JWS."
64
- }
31
+ "kyc_jws": { "type": "string", "description": "The attestation as a compact EdDSA JWS." }
65
32
  }
66
33
  },
67
34
  "verification": {
68
35
  "type": "object",
69
36
  "description": "The answer to the request_kyc action: a verification opened at the KYC provider, whose page the AI assistant relays to its human.",
70
37
  "additionalProperties": false,
71
- "required": [
72
- "request_id",
73
- "verification_url",
74
- "status"
75
- ],
38
+ "required": ["request_id", "verification_url", "status"],
76
39
  "properties": {
77
- "request_id": {
78
- "type": "string",
79
- "description": "The provider's id for this verification; the kyc_verification event carries it back."
80
- },
81
- "verification_url": {
82
- "type": "string",
83
- "description": "The provider page the human completes."
84
- },
85
- "status": {
86
- "const": "pending"
87
- }
40
+ "request_id": { "type": "string", "description": "The provider's id for this verification; the kyc_verification event carries it back." },
41
+ "verification_url": { "type": "string", "description": "The provider page the human completes." },
42
+ "status": { "const": "pending" }
88
43
  }
89
44
  },
90
45
  "verificationEvent": {
91
46
  "type": "object",
92
47
  "description": "The data of a kyc_verification event: the human approved, and the event carries the provider-signed attestation to submit to POST /kiosk/agents/kyc.",
93
48
  "additionalProperties": false,
94
- "required": [
95
- "request_id",
96
- "status",
97
- "kyc_jws"
98
- ],
49
+ "required": ["request_id", "status", "kyc_jws"],
99
50
  "properties": {
100
- "request_id": {
101
- "type": "string",
102
- "description": "The request_id request_kyc returned."
103
- },
104
- "status": {
105
- "const": "approved"
106
- },
107
- "kyc_jws": {
108
- "type": "string",
109
- "description": "The attestation as a compact RS256 JWS."
110
- }
51
+ "request_id": { "type": "string", "description": "The request_id request_kyc returned." },
52
+ "status": { "const": "approved" },
53
+ "kyc_jws": { "type": "string", "description": "The attestation as a compact EdDSA JWS." }
111
54
  }
112
55
  }
113
56
  }
@@ -2,73 +2,157 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://kiosk.tech/spec/schemas/mandates.schema.json",
4
4
  "$comment": "VENDORED copy of the normative kiosk.tech schema at spec/schemas/mandates.schema.json. It is here so RequestValidation can hold a reserved-plane request body to it at runtime, and so e2e/schema_conformance.rb can validate this origin's LIVE wire bytes against the PUBLISHED schema -- both with no second checkout and no network fetch. Identical to the upstream copy as PARSED JSON once this $comment is removed.",
5
- "title": "Kiosk AP2 payment mandates",
6
- "description": "The decoded JWS payloads of the three AP2 mandates and the POST /kiosk/pay request body. On the wire each mandate is an RS256 compact JWS string; these schemas describe the decoded claim sets.",
5
+ "title": "Kiosk payment: the checkout and the AP2 v0.2 mandates",
6
+ "description": "The checkout an operator signs, the two closed AP2 v0.2 mandates an AI assistant signs to pay it, the POST <endpoint>/pay request body, the settlement it answers and the two receipts in it. On the wire the checkout is a compact EdDSA JWT, and each mandate is a compact SD-JWT whose one disclosure is the mandate; these schemas describe the decoded objects.",
7
7
  "$defs": {
8
- "baseClaims": {
8
+ "currency": {
9
+ "type": "string",
10
+ "minLength": 1,
11
+ "pattern": "^[A-Za-z]{3}$",
12
+ "description": "An ISO 4217 ALPHA-3 code, and the `pattern` is the domain: three ASCII letters, either case. An operator MAY refuse more (Section 11.1)."
13
+ },
14
+ "merchant": {
15
+ "type": "object",
16
+ "description": "The AP2 Merchant object. In a Kiosk checkout, `id` and `website` are the operator's issuer.",
17
+ "required": ["id", "name"],
18
+ "properties": {
19
+ "id": { "type": "string" },
20
+ "name": { "type": "string" },
21
+ "website": { "type": "string" }
22
+ }
23
+ },
24
+ "checkout": {
9
25
  "type": "object",
10
- "description": "Claims every mandate MUST carry.",
11
- "required": ["id", "user_id", "agent_id", "iss", "iat", "exp"],
26
+ "description": "The claims of the `checkout_jwt` an operator answers for an item to pay for: a JWT it signs EdDSA with the key of its JWKS (Section 11.7).",
27
+ "required": ["iss", "sub", "iat", "jti", "merchant", "currency", "total_amount_cents", "line_items"],
12
28
  "properties": {
13
- "id": { "type": "string", "description": "Unique mandate id." },
14
- "user_id": { "type": "string", "description": "MUST match the authenticated identity (string-compared)." },
15
- "agent_id": { "type": "string", "description": "MUST match the authenticated identity (string-compared)." },
16
- "iss": { "type": "string", "description": "MUST equal the provider issuer from /.well-known/kiosk.json, verbatim." },
29
+ "iss": { "type": "string", "description": "The operator's issuer, verbatim from kiosk.json." },
30
+ "sub": { "type": "string", "description": "The account the checkout is for. Only that account can pay it." },
17
31
  "iat": { "type": "integer" },
18
- "exp": { "type": "integer", "description": "An exp already in the past is rejected." }
32
+ "jti": { "type": "string", "minLength": 22, "description": "At least 128 random bits, base64url. An EdDSA signature is the same for the same claims, so this value makes the hash of each checkout impossible to guess." },
33
+ "merchant": { "$ref": "#/$defs/merchant" },
34
+ "currency": { "$ref": "#/$defs/currency" },
35
+ "total_amount_cents": { "type": "integer", "minimum": 1, "description": "MUST be positive." },
36
+ "line_items": { "type": "array", "minItems": 1, "items": { "type": "object" }, "description": "The first names the item to pay for; the others carry `qty` and `price_cents`, which sum to the total." }
19
37
  }
20
38
  },
21
- "intent": {
22
- "allOf": [{ "$ref": "#/$defs/baseClaims" }],
39
+ "sdJwtPayload": {
23
40
  "type": "object",
24
- "required": ["cap_amount_cents", "currency"],
41
+ "description": "The payload of the issuer-signed JWT of a mandate. Its header is {\"alg\": \"EdDSA\", \"typ\": \"dc+sd-jwt\"}, signed with the AI assistant's registered key; a P-256 key signs ES256. `delegate_payload` holds one digest, and the one disclosure it names is the mandate.",
42
+ "required": ["delegate_payload"],
25
43
  "properties": {
26
- "cap_amount_cents": { "type": "integer", "minimum": 1, "description": "Spending cap; there is no \"no cap\" spelling -- a nil cap is rejected. MUST be positive; a non-positive amount is rejected." },
27
- "currency": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z]{3}$", "description": "An ISO 4217 ALPHA-3 code, and the `pattern` is the domain: three ASCII letters, either case. An operator MAY refuse more (Section 11.1)."},
28
- "scope": { "type": "string", "description": "Provider-defined scope." }
44
+ "_sd_alg": { "const": "sha-256" },
45
+ "delegate_payload": {
46
+ "type": "array",
47
+ "minItems": 1,
48
+ "maxItems": 1,
49
+ "items": {
50
+ "type": "object",
51
+ "required": ["..."],
52
+ "properties": { "...": { "type": "string" } },
53
+ "additionalProperties": false
54
+ }
55
+ }
29
56
  }
30
57
  },
31
- "cart": {
32
- "allOf": [{ "$ref": "#/$defs/baseClaims" }],
58
+ "lifetime": {
33
59
  "type": "object",
34
- "required": ["intent_mandate_id", "total_amount_cents", "currency", "line_items"],
60
+ "required": ["iat", "exp"],
35
61
  "properties": {
36
- "intent_mandate_id": { "type": "string", "description": "MUST equal the intent mandate id; cart total MUST NOT exceed the intent cap." },
37
- "total_amount_cents": { "type": "integer", "minimum": 1, "description": "MUST be positive; a non-positive amount is rejected." },
38
- "currency": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z]{3}$", "description": "MUST equal the intent currency, and MUST be an ISO 4217 alpha-3 code -- see the intent's note; equality to another empty string, or to another currency NAME, is not a check." },
39
- "line_items": { "type": "array", "minItems": 1, "description": "Provider-defined line items, at least one. The operator matches a settlement to its own record through them." }
62
+ "iat": { "type": "integer" },
63
+ "exp": { "type": "integer", "description": "At most 24 hours ahead. An exp already in the past is rejected." }
40
64
  }
41
65
  },
42
- "payment": {
43
- "allOf": [{ "$ref": "#/$defs/baseClaims" }],
66
+ "checkoutMandate": {
67
+ "allOf": [{ "$ref": "#/$defs/lifetime" }],
44
68
  "type": "object",
45
- "required": ["cart_mandate_id", "amount_cents", "currency"],
69
+ "description": "The closed AP2 Checkout Mandate.",
70
+ "required": ["vct", "checkout_jwt", "checkout_hash"],
46
71
  "properties": {
47
- "cart_mandate_id": { "type": "string", "description": "MUST equal the cart mandate id." },
48
- "amount_cents": { "type": "integer", "minimum": 1, "description": "MUST equal the cart total, same currency. MUST be positive; a non-positive amount is rejected." },
49
- "currency": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z]{3}$", "description": "MUST equal the cart currency, and MUST be an ISO 4217 alpha-3 code." },
50
- "payment_method": { "type": "string", "description": "PSP method identifier (e.g. \"on_file\")." }
72
+ "vct": { "const": "mandate.checkout.1" },
73
+ "checkout_jwt": { "type": "string", "description": "The checkout_jwt the operator answered, unchanged." },
74
+ "checkout_hash": { "type": "string", "description": "The base64url SHA-256 of checkout_jwt, with no padding." }
75
+ }
76
+ },
77
+ "paymentMandate": {
78
+ "allOf": [{ "$ref": "#/$defs/lifetime" }],
79
+ "type": "object",
80
+ "description": "The closed AP2 Payment Mandate.",
81
+ "required": ["vct", "transaction_id", "payee", "payment_amount", "payment_instrument"],
82
+ "properties": {
83
+ "vct": { "const": "mandate.payment.1" },
84
+ "transaction_id": { "type": "string", "description": "The checkout_hash of the checkout mandate." },
85
+ "payee": { "$ref": "#/$defs/merchant", "description": "The checkout's merchant: its id is the operator's issuer." },
86
+ "payment_amount": {
87
+ "type": "object",
88
+ "required": ["amount", "currency"],
89
+ "properties": {
90
+ "amount": { "type": "integer", "minimum": 1, "description": "The checkout's total_amount_cents." },
91
+ "currency": { "$ref": "#/$defs/currency", "description": "The checkout's currency, compared as a code." }
92
+ }
93
+ },
94
+ "payment_instrument": {
95
+ "type": "object",
96
+ "required": ["id", "type"],
97
+ "properties": {
98
+ "id": { "type": "string", "minLength": 1, "description": "\"on_file\" pays with the card the human saved through payment_setup." },
99
+ "type": { "type": "string", "minLength": 1 },
100
+ "description": { "type": "string" }
101
+ }
102
+ }
51
103
  }
52
104
  },
53
105
  "payRequest": {
54
106
  "type": "object",
55
- "description": "The POST /kiosk/pay request body: the three mandates as compact JWS strings.",
56
- "required": ["intent_mandate_jws", "cart_mandate_jws", "payment_mandate_jws"],
107
+ "description": "The POST <endpoint>/pay request body: the two closed mandates as compact SD-JWT strings.",
108
+ "required": ["checkout_mandate", "payment_mandate"],
57
109
  "properties": {
58
- "intent_mandate_jws": { "type": "string" },
59
- "cart_mandate_jws": { "type": "string" },
60
- "payment_mandate_jws": { "type": "string" }
110
+ "checkout_mandate": { "type": "string", "pattern": "~$" },
111
+ "payment_mandate": { "type": "string", "pattern": "~$" }
61
112
  }
62
113
  },
63
114
  "settlement": {
64
115
  "type": "object",
65
116
  "description": "The pay response body, answered verbatim: there is no envelope, no ok flag and no kind discriminator (protocol.md 8.2, 11.3).",
66
- "required": ["settlement_id", "psp_reference", "settled_amount_cents", "currency"],
117
+ "required": ["settlement_id", "psp_reference", "settled_amount_cents", "currency", "checkout_receipt", "payment_receipt"],
67
118
  "properties": {
68
119
  "settlement_id": { "type": "string" },
69
120
  "psp_reference": { "type": "string" },
70
- "settled_amount_cents": { "type": "integer", "minimum": 1, "description": "The captured amount; equals the cart total. MUST be positive: a settlement exists only for a completed capture, and every mandate amount is already positive." },
71
- "currency": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z]{3}$", "description": "The currency of the capture: the cart's currency in the OPERATOR's canonical spelling, which MAY differ byte for byte from the spelling the assistant signed (protocol.md 11.3, 11.5). Compare it as an ISO 4217 code, never as bytes." }
121
+ "settled_amount_cents": { "type": "integer", "minimum": 1, "description": "The captured amount; equals the checkout total. MUST be positive: a settlement exists only for a completed capture." },
122
+ "currency": { "$ref": "#/$defs/currency", "description": "The currency of the capture, in the OPERATOR's canonical spelling, which MAY differ byte for byte from the spelling in the checkout (protocol.md 11.3, 11.5). Compare it as an ISO 4217 code, never as bytes." },
123
+ "checkout_receipt": { "$ref": "#/$defs/compactJwt", "description": "The AP2 checkout receipt: a compact JWT the operator signs EdDSA with the key of its JWKS. Its claims are `checkoutReceipt` (protocol.md 11.3)." },
124
+ "payment_receipt": { "$ref": "#/$defs/compactJwt", "description": "The AP2 payment receipt: a compact JWT the operator signs EdDSA with the key of its JWKS. Its claims are `paymentReceipt` (protocol.md 11.3)." }
125
+ }
126
+ },
127
+ "compactJwt": {
128
+ "type": "string",
129
+ "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$",
130
+ "description": "A compact JWS: three base64url segments."
131
+ },
132
+ "checkoutReceipt": {
133
+ "type": "object",
134
+ "description": "The claims of the checkout receipt a successful pay answers (AP2 v0.2 checkout_receipt.json, status Success).",
135
+ "required": ["status", "iss", "iat", "reference", "order_id"],
136
+ "properties": {
137
+ "status": { "const": "Success" },
138
+ "iss": { "type": "string", "description": "The operator's issuer." },
139
+ "iat": { "type": "integer" },
140
+ "reference": { "type": "string", "description": "The reference of the checkout mandate: the base64url SHA-256 of its compact SD-JWT." },
141
+ "order_id": { "type": "string", "description": "The id in the first line item of the checkout." }
142
+ }
143
+ },
144
+ "paymentReceipt": {
145
+ "type": "object",
146
+ "description": "The claims of the payment receipt a successful pay answers (AP2 v0.2 payment_receipt.json, status Success). `network_confirmation_id` is present only when the payment provider reports it; AP2 requires it, so a receipt without it does not satisfy AP2's schema (protocol.md 11.3).",
147
+ "required": ["status", "iss", "iat", "reference", "payment_id", "psp_confirmation_id"],
148
+ "properties": {
149
+ "status": { "const": "Success" },
150
+ "iss": { "type": "string", "description": "The operator's issuer." },
151
+ "iat": { "type": "integer" },
152
+ "reference": { "type": "string", "description": "The reference of the payment mandate: the base64url SHA-256 of its compact SD-JWT." },
153
+ "payment_id": { "type": "string", "description": "The settlement_id." },
154
+ "psp_confirmation_id": { "type": "string", "description": "The psp_reference." },
155
+ "network_confirmation_id": { "type": "string", "description": "The card network's id of the transaction." }
72
156
  }
73
157
  }
74
158
  }
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'digest'
5
+ require 'json'
6
+ require 'jwt'
7
+
8
+ module Kiosk
9
+ module Server
10
+ # The part of SD-JWT (RFC 9901) that an AP2 closed mandate needs: a compact
11
+ # SD-JWT with no key-binding JWT, its issuer-signed JWT verified with one
12
+ # key, and each disclosure put back in place of its digest.
13
+ module SdJwt
14
+ # The token is not a well-formed SD-JWT, or its disclosures do not match it.
15
+ class Invalid < StandardError
16
+ end
17
+
18
+ SD_ALG = 'sha-256'
19
+
20
+ module_function
21
+
22
+ # @return [Array(Hash, Hash)] the payload with every disclosure in place, and the JWT header
23
+ # @raise [Invalid, JWT::DecodeError]
24
+ def verify(token, key:, algorithm:)
25
+ jwt, disclosures = split(token)
26
+ payload, header = ::JWT.decode(jwt, key, true, algorithms: [algorithm])
27
+ sd_alg = payload.delete('_sd_alg') || SD_ALG
28
+ raise Invalid, "_sd_alg is #{sd_alg.inspect}; only #{SD_ALG} is accepted" unless sd_alg == SD_ALG
29
+
30
+ [disclose(payload, disclosures), header]
31
+ end
32
+
33
+ # The base64url SHA-256 of `text`: a disclosure's digest, and a mandate's reference.
34
+ def digest(text) = Base64.urlsafe_encode64(Digest::SHA256.digest(text), padding: false)
35
+
36
+ # `payload` with each disclosure in place. RFC 9901 §7.1: a disclosure that
37
+ # no digest names, or two disclosures with one digest, refuse the token.
38
+ def disclose(payload, disclosures)
39
+ pending = disclosures.to_h { |d| [digest(d), decode_disclosure(d)] }
40
+ raise Invalid, 'two disclosures have the same digest' if pending.size < disclosures.size
41
+
42
+ resolved = resolve(payload, pending)
43
+ raise Invalid, 'a disclosure is not named by any digest' unless pending.empty?
44
+
45
+ resolved
46
+ end
47
+
48
+ # A compact SD-JWT ends in `~`: anything after it is a key-binding JWT,
49
+ # and an empty part is a chain of SD-JWTs.
50
+ def split(token)
51
+ raise Invalid, 'an SD-JWT is a string' unless token.is_a?(String)
52
+
53
+ jwt, *disclosures, binding = token.split('~', -1)
54
+ raise Invalid, 'an SD-JWT ends with "~" and has no key-binding JWT' unless binding == ''
55
+ raise Invalid, 'an SD-JWT has no empty part' if jwt.to_s.empty? || disclosures.any?(&:empty?)
56
+
57
+ [jwt, disclosures]
58
+ end
59
+
60
+ def decode_disclosure(disclosure)
61
+ array = JSON.parse(Base64.urlsafe_decode64(disclosure))
62
+ return array if array.is_a?(Array) && [2, 3].include?(array.size) && array.first.is_a?(String)
63
+
64
+ raise Invalid, 'a disclosure is an array of [salt, value] or [salt, name, value]'
65
+ rescue ArgumentError, JSON::ParserError
66
+ raise Invalid, 'a disclosure is base64url-encoded JSON'
67
+ end
68
+
69
+ def resolve(value, pending)
70
+ case value
71
+ when Hash then resolve_object(value, pending)
72
+ when Array then value.each_with_object([]) { |element, out| resolve_element(element, pending, out) }
73
+ else value
74
+ end
75
+ end
76
+
77
+ # The `_sd` digests become properties; a digest with no disclosure is a decoy.
78
+ def resolve_object(object, pending)
79
+ result = object.except('_sd').transform_values { |v| resolve(v, pending) }
80
+ Array(object['_sd']).each do |d|
81
+ disclosure = pending.delete(d) || next
82
+ raise Invalid, 'an object disclosure is [salt, name, value]' unless disclosure.size == 3
83
+
84
+ _salt, name, value = disclosure
85
+ raise Invalid, "the disclosed name #{name.inspect} is already in the object" if result.key?(name)
86
+
87
+ result[name] = resolve(value, pending)
88
+ end
89
+ result
90
+ end
91
+
92
+ # `{"...": digest}` becomes the disclosed element; one with no disclosure is left out.
93
+ def resolve_element(element, pending, out)
94
+ return out << resolve(element, pending) unless element.is_a?(Hash) && element.keys == ['...']
95
+
96
+ disclosure = pending.delete(element['...']) || return
97
+ raise Invalid, 'an array disclosure is [salt, value]' unless disclosure.size == 2
98
+
99
+ out << resolve(disclosure.last, pending)
100
+ end
101
+ private_class_method :split, :decode_disclosure, :resolve, :resolve_object, :resolve_element
102
+ end
103
+ end
104
+ end
@@ -1,82 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'openssl'
4
- require 'base64'
5
- require 'digest'
6
- require 'json'
7
4
 
8
5
  module Kiosk
9
6
  module Server
10
- # The RSA key that signs this deployment's JWTs, and its public JWK.
7
+ # The Ed25519 key that signs this deployment's JWTs (EdDSA, RFC 8037): its
8
+ # access tokens, checkouts and receipts. Its public JWK names `alg` EdDSA.
11
9
  class SigningKey
12
- MIN_KEY_BITS = 2048
10
+ HINT = 'generate one with `openssl genpkey -algorithm ed25519`'
13
11
 
14
- ALGORITHM = 'RS256'
12
+ attr_reader :key
15
13
 
16
- attr_reader :rsa
14
+ def initialize(key)
15
+ raise ArgumentError, "the signing key is an Ed25519 key, got #{key.class}; #{HINT}" unless Jwk.ed25519?(key)
17
16
 
18
- def initialize(rsa)
19
- raise ArgumentError, "expected OpenSSL::PKey::RSA, got #{rsa.class}" unless rsa.is_a?(OpenSSL::PKey::RSA)
20
- if rsa.n.num_bits < MIN_KEY_BITS
21
- raise ArgumentError,
22
- "RSA key is #{rsa.n.num_bits} bits; minimum is #{MIN_KEY_BITS}"
23
- end
24
- @rsa = rsa
17
+ @key = key
25
18
  end
26
19
 
27
- def self.generate(bits: MIN_KEY_BITS)
28
- new(OpenSSL::PKey::RSA.generate(bits))
20
+ def self.generate
21
+ new(OpenSSL::PKey.generate_key('ED25519'))
29
22
  end
30
23
 
31
- # A public-only PEM verifies; a full keypair also signs.
24
+ # A public-only PEM verifies; a private PEM also signs.
32
25
  def self.from_pem(pem)
33
- new(OpenSSL::PKey::RSA.new(pem))
26
+ new(OpenSSL::PKey.read(pem))
27
+ rescue OpenSSL::PKey::PKeyError
28
+ raise ArgumentError, "the signing key is not a PEM key; #{HINT}"
34
29
  end
35
30
 
36
- delegate :public_key, to: :rsa
37
-
38
- delegate :private?, to: :rsa
31
+ def private? = Jwk.private?(key)
39
32
 
40
33
  def to_pem
41
34
  raise 'cannot export PEM: signing key is public-only' unless private?
42
35
 
43
- rsa.to_pem
36
+ key.private_to_pem
44
37
  end
45
38
 
46
39
  # The RFC 7638 thumbprint.
47
40
  def kid
48
- @kid ||= compute_thumbprint
49
- end
50
-
51
- # RFC 7517 §4; never the private parameters.
52
- def to_jwk
53
- {
54
- kty: 'RSA',
55
- use: 'sig',
56
- alg: ALGORITHM,
57
- kid: kid,
58
- n: b64url(rsa.n.to_s(2)),
59
- e: b64url(rsa.e.to_s(2))
60
- }
41
+ @kid ||= Jwk.thumbprint(key)
61
42
  end
62
43
 
63
- private
64
-
65
- def compute_thumbprint
66
- # RFC 7638 §3.2: the required members, in alphabetical order.
67
- canonical = JSON.generate(
68
- {
69
- e: b64url(rsa.e.to_s(2)),
70
- kty: 'RSA',
71
- n: b64url(rsa.n.to_s(2))
72
- }
73
- )
74
- b64url(Digest::SHA256.digest(canonical))
75
- end
76
-
77
- def b64url(bytes)
78
- Base64.urlsafe_encode64(bytes, padding: false)
79
- end
44
+ # RFC 7517 §4; never the private member.
45
+ def to_jwk = { **Jwk.public_members(key), use: 'sig', alg: Eddsa::NAME, kid: kid }
80
46
  end
81
47
  end
82
48
  end
@@ -18,7 +18,7 @@ module Kiosk
18
18
 
19
19
  def resolve_identity!
20
20
  identity = IdentityResolution.resolve(request)
21
- raise Errors::Unauthenticated, 'no identity resolved from request' if identity.nil?
21
+ raise Errors::AccessTokenRefused if identity.nil?
22
22
 
23
23
  identity
24
24
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- VERSION = '0.5.20'
5
+ VERSION = '0.6.1'
6
6
  end
7
7
  end