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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +63 -41
- data/app/controllers/kiosk/server/auth_controller.rb +18 -62
- data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
- data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
- data/app/models/kiosk/checkout_mandate.rb +22 -0
- data/app/models/kiosk/settlement.rb +1 -1
- data/lib/generators/kiosk/install/install_generator.rb +0 -5
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +19 -58
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
- data/lib/kiosk/server/agent_rotation.rb +62 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +1 -1
- data/lib/kiosk/server/binding_module_gate.rb +1 -1
- data/lib/kiosk/server/checkout.rb +57 -0
- data/lib/kiosk/server/client.rb +61 -0
- data/lib/kiosk/server/client_assertion.rb +153 -0
- data/lib/kiosk/server/client_registration.rb +94 -0
- data/lib/kiosk/server/configuration_extension.rb +56 -44
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +121 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +14 -11
- data/lib/kiosk/server/errors.rb +7 -0
- data/lib/kiosk/server/events_connection.rb +15 -5
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +25 -26
- data/lib/kiosk/server/executor.rb +82 -101
- data/lib/kiosk/server/identity_resolution.rb +12 -3
- data/lib/kiosk/server/jwk.rb +83 -0
- data/lib/kiosk/server/jwks.rb +6 -0
- data/lib/kiosk/server/jwt_issuer.rb +7 -13
- data/lib/kiosk/server/kyc_verifier.rb +2 -2
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +1 -1
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/oauth_answers.rb +30 -0
- data/lib/kiosk/server/oauth_error.rb +30 -0
- data/lib/kiosk/server/open_api.rb +22 -20
- data/lib/kiosk/server/payment_claim.rb +44 -75
- data/lib/kiosk/server/receipts.rb +29 -0
- data/lib/kiosk/server/request_validation.rb +1 -2
- data/lib/kiosk/server/revocation_stores.rb +8 -0
- data/lib/kiosk/server/schema_definitions.rb +33 -64
- data/lib/kiosk/server/schemas/auth.schema.json +368 -57
- data/lib/kiosk/server/schemas/binding.schema.json +23 -58
- data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
- data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/verb_wire.rb +1 -1
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +268 -204
- data/lib/kiosk/server/wire_rendering.rb +1 -1
- data/lib/kiosk/server.rb +2 -0
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +26 -17
- data/app/models/kiosk/cart_mandate.rb +0 -21
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
- data/lib/kiosk/server/agent_login.rb +0 -49
- data/lib/kiosk/server/agent_registration.rb +0 -88
- data/lib/kiosk/server/auth_challenge.rb +0 -33
- data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
- 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
|
-
|
|
21
|
-
|
|
22
|
-
},
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
6
|
-
"description": "The
|
|
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
|
-
"
|
|
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": "
|
|
11
|
-
"required": ["
|
|
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
|
-
"
|
|
14
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
22
|
-
"allOf": [{ "$ref": "#/$defs/baseClaims" }],
|
|
39
|
+
"sdJwtPayload": {
|
|
23
40
|
"type": "object",
|
|
24
|
-
"
|
|
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
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
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
|
-
"
|
|
32
|
-
"allOf": [{ "$ref": "#/$defs/baseClaims" }],
|
|
58
|
+
"lifetime": {
|
|
33
59
|
"type": "object",
|
|
34
|
-
"required": ["
|
|
60
|
+
"required": ["iat", "exp"],
|
|
35
61
|
"properties": {
|
|
36
|
-
"
|
|
37
|
-
"
|
|
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
|
-
"
|
|
43
|
-
"allOf": [{ "$ref": "#/$defs/
|
|
66
|
+
"checkoutMandate": {
|
|
67
|
+
"allOf": [{ "$ref": "#/$defs/lifetime" }],
|
|
44
68
|
"type": "object",
|
|
45
|
-
"
|
|
69
|
+
"description": "The closed AP2 Checkout Mandate.",
|
|
70
|
+
"required": ["vct", "checkout_jwt", "checkout_hash"],
|
|
46
71
|
"properties": {
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
|
|
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
|
|
56
|
-
"required": ["
|
|
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
|
-
"
|
|
59
|
-
"
|
|
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
|
|
71
|
-
"currency": { "
|
|
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
|
|
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
|
-
|
|
10
|
+
HINT = 'generate one with `openssl genpkey -algorithm ed25519`'
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
attr_reader :key
|
|
15
13
|
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
28
|
-
new(OpenSSL::PKey
|
|
20
|
+
def self.generate
|
|
21
|
+
new(OpenSSL::PKey.generate_key('ED25519'))
|
|
29
22
|
end
|
|
30
23
|
|
|
31
|
-
# A public-only PEM verifies; a
|
|
24
|
+
# A public-only PEM verifies; a private PEM also signs.
|
|
32
25
|
def self.from_pem(pem)
|
|
33
|
-
new(OpenSSL::PKey
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
key.private_to_pem
|
|
44
37
|
end
|
|
45
38
|
|
|
46
39
|
# The RFC 7638 thumbprint.
|
|
47
40
|
def kid
|
|
48
|
-
@kid ||=
|
|
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
|