kiosk-reputation 0.5.20
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 +7 -0
- data/CHANGELOG.md +121 -0
- data/LICENSE.txt +202 -0
- data/README.md +310 -0
- data/lib/kiosk/reputation/backends.rb +45 -0
- data/lib/kiosk/reputation/challenge.rb +147 -0
- data/lib/kiosk/reputation/factors.rb +12 -0
- data/lib/kiosk/reputation/policies/recent_spend.rb +49 -0
- data/lib/kiosk/reputation/policy.rb +15 -0
- data/lib/kiosk/reputation/version.rb +7 -0
- data/lib/kiosk/reputation.rb +15 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2c54f86a684320da6e9531f75045355e684195d3cea1fe21f446faa078803edc
|
|
4
|
+
data.tar.gz: 478787af3127d2ff0a839c9b121e815576fe3d99b1e316292246c64ceef81fd6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 65d3819b9a2e14b7409a9acb582d24e5bd5d63f9a161ae78a6138f4a6fc9d512e2df1a4ce17747a0914e84c808b05087236f1cb69e90d2933f6c8d91e53d1dd0
|
|
7
|
+
data.tar.gz: 5d86be174fc6b13babb23c5cdf476d637be020c618d9b9bfd7611c8405afb077cc464950faaff156b42e18ce2d7f3a341475affe446b4e7709a844a849952886
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
How to write an entry, and what a release section means: `CHANGELOG-RULE.md` at
|
|
6
|
+
the root of this repository —
|
|
7
|
+
<https://github.com/kiosk-hq/kiosk/blob/main/CHANGELOG-RULE.md>. In short: under
|
|
8
|
+
200 characters, one or two sentences, the essence rather than the content; write it
|
|
9
|
+
under `## [Unreleased]`; a cut renames that heading to
|
|
10
|
+
`## [MAJOR.MINOR.PATCH] — <date>` and opens a fresh empty one above it; nothing
|
|
11
|
+
already written is edited.
|
|
12
|
+
|
|
13
|
+
## [Unreleased]
|
|
14
|
+
|
|
15
|
+
## [0.5.20] — 2026-10-20
|
|
16
|
+
|
|
17
|
+
- 2026-10-20: Version 0.5.20, the tree cut that matches skill 0.5.20.
|
|
18
|
+
|
|
19
|
+
## [0.5.19] — 2026-10-20
|
|
20
|
+
|
|
21
|
+
- 2026-10-20: Version 0.5.19, the tree cut that matches skill 0.5.19.
|
|
22
|
+
|
|
23
|
+
## [0.5.18] — 2026-10-18
|
|
24
|
+
|
|
25
|
+
- 2026-10-18: Version 0.5.18, the tree cut that matches skill 0.5.18.
|
|
26
|
+
|
|
27
|
+
## [0.5.17] — 2026-10-18
|
|
28
|
+
|
|
29
|
+
- 2026-10-18: `RecentSpend` tolls every call of a caller whose token has no `iat` or `exp`, so a retry cannot fish for a free draw.
|
|
30
|
+
- 2026-10-18: `Policies::RateAndReputation` is removed; `Factors` keeps only `settled_purchases_count` and `settled_purchases_cents`.
|
|
31
|
+
- 2026-10-18: The README configures `RecentSpend`; `RateAndReputation` gets no factors behind the gate, and `on_bad_proof` records into a store the host owns.
|
|
32
|
+
- 2026-10-18: `Policies::Backoff` and `BackoffStore` are removed.
|
|
33
|
+
- 2026-10-18: `RecentSpend` draws once for each customer in each token lifetime, so a new access token in the same lifetime meets the same outcome.
|
|
34
|
+
- 2026-10-18: `RecentSpend` draws once per access token: the token's `jti` fixes the draw, so every call made with the token meets one outcome. `random:` is removed.
|
|
35
|
+
|
|
36
|
+
## [0.5.16] — 2026-10-18
|
|
37
|
+
|
|
38
|
+
- 2026-10-18: The README names kiosk-pow-argon2id for the Argon2id backend and points to kiosk-pow's guide to add a backend.
|
|
39
|
+
|
|
40
|
+
## [0.5.15] — 2026-10-18
|
|
41
|
+
|
|
42
|
+
- `Policies::RecentSpend.new(within:, cap:)` tolls a session with the chance `max(0, (cap - spend) / cap)`, `cap` in whole currency units; `trusted_at:` and `count:` are gone.
|
|
43
|
+
|
|
44
|
+
- The README says that `kiosk-pow-equihash` registers itself at Rails boot, and that the host registers any other backend.
|
|
45
|
+
|
|
46
|
+
## [0.5.14] — 2026-10-18
|
|
47
|
+
|
|
48
|
+
- 2026-10-18: Version 0.5.14, the tree cut that matches skill 0.5.14.
|
|
49
|
+
|
|
50
|
+
- 2026-10-18: `Policies::RecentSpend` asks one toll per session and lowers it by the customer's recent spend, to none at `trusted_at`.
|
|
51
|
+
|
|
52
|
+
- 2026-10-17: The docs say kiosk-pow-equihash registers itself in a Rails app; the ambiguity error no longer names the removed `registration_pow_params`.
|
|
53
|
+
|
|
54
|
+
- 2026-10-17: `Challenge.check` runs the signature, expiry and parameter checks alone, without the proof evaluation.
|
|
55
|
+
## [0.5.13] — 2026-10-11
|
|
56
|
+
|
|
57
|
+
- 2026-10-11: Version 0.5.13, the tree cut that matches skill 0.5.13; this gem's surface is unchanged.
|
|
58
|
+
|
|
59
|
+
## [0.5.12] — 2026-10-08
|
|
60
|
+
|
|
61
|
+
- 2026-10-08: Version 0.5.12, the tree cut that matches skill 0.5.12; this gem's surface is unchanged.
|
|
62
|
+
|
|
63
|
+
## [0.5.11] — 2026-10-08
|
|
64
|
+
|
|
65
|
+
- 2026-10-08: Version 0.5.11, the tree cut that matches skill 0.5.11; this gem's surface is unchanged.
|
|
66
|
+
|
|
67
|
+
## [0.5.10] — 2026-10-08
|
|
68
|
+
|
|
69
|
+
- 2026-10-08: Version 0.5.10, the tree cut that matches skill 0.5.10; this gem's surface is unchanged.
|
|
70
|
+
|
|
71
|
+
## [0.5.9] — 2026-10-08
|
|
72
|
+
|
|
73
|
+
- 2026-10-08: Version 0.5.9, the tree cut that matches skill 0.5.9; this gem's surface is unchanged.
|
|
74
|
+
|
|
75
|
+
## [0.5.8] — 2026-10-07
|
|
76
|
+
|
|
77
|
+
- 2026-10-07: Version 0.5.8, the tree cut that matches skill 0.5.8; this gem's surface is unchanged.
|
|
78
|
+
|
|
79
|
+
## [0.5.7] — 2026-10-07
|
|
80
|
+
|
|
81
|
+
- 2026-10-07: Version 0.5.7, the tree cut that matches skill 0.5.7; this gem's surface is unchanged.
|
|
82
|
+
|
|
83
|
+
## [0.5.6] — 2026-10-07
|
|
84
|
+
|
|
85
|
+
- 2026-10-07: Version 0.5.6, the tree cut that matches skill 0.5.6; this gem's surface is unchanged.
|
|
86
|
+
|
|
87
|
+
## [0.5.5] — 2026-10-07
|
|
88
|
+
|
|
89
|
+
- 2026-10-07: Version 0.5.5, the tree cut that matches skill 0.5.5; this gem's surface is unchanged.
|
|
90
|
+
|
|
91
|
+
## [0.5.4] — 2026-10-07
|
|
92
|
+
|
|
93
|
+
- 2026-10-07: Version 0.5.4, the tree cut that matches skill 0.5.4; this gem's surface is unchanged.
|
|
94
|
+
|
|
95
|
+
## [0.5.3] — 2026-10-06
|
|
96
|
+
|
|
97
|
+
- 2026-10-06: Version 0.5.3, the tree cut that matches skill 0.5.3; this gem's surface is unchanged.
|
|
98
|
+
|
|
99
|
+
## [0.5.2] — 2026-10-06
|
|
100
|
+
|
|
101
|
+
- 2026-10-06: Version 0.5.2, the tree cut that matches skill 0.5.2; this gem's surface is unchanged.
|
|
102
|
+
|
|
103
|
+
## [0.5.0] — 2026-09-26
|
|
104
|
+
|
|
105
|
+
### Changed
|
|
106
|
+
|
|
107
|
+
- README: every `gem` line in the install section now carries `github: "kiosk-hq/kiosk"`, so a copied line resolves before publication; the banner above them states what the block does rather than asking the reader to add it.
|
|
108
|
+
- **Three packaged sentences priced a solve at «~9 s» with no solver, machine or parameters (K-1694).** The seconds are removed; the count-not-window argument is unchanged.
|
|
109
|
+
- **A signed challenge could be re-partitioned into another alg/params split under its own HMAC (K-1693).** `Challenge` now refuses a canonical-string delimiter in any field.
|
|
110
|
+
- **`Policy#challenge_for`'s `verb:` contract is stated as closed, and a wrong branch is no longer silent (K-1395).** The hook receives one of `:query`, `:run`, `:pay` and nothing else; a handler declared `kind :action` in kiosk-server arrives as `:run`. Branching on `:action` matched nothing and declined to toll every write with no error, log line or failing test. `kiosk-server` now refuses such a policy at configuration time. No behaviour change in this gem.
|
|
111
|
+
|
|
112
|
+
### Added
|
|
113
|
+
|
|
114
|
+
- Initial implementation.
|
|
115
|
+
- `Kiosk::Reputation::Backends` — algorithm registry: `register` / `fetch` / `known` / `reset!`.
|
|
116
|
+
- `Kiosk::Reputation::Backends.valid_params?(alg, params)` — the mint-time seam a gate asks before issuing a challenge (K-843). Duck-typed and opt-in: it answers `false` only when a registered backend says so itself, and `true` for an unregistered algorithm or one that expresses no opinion, so it can never invent a refusal.
|
|
117
|
+
- `Kiosk::Reputation::Challenge` — stateless, request-bound wire challenge: `issue` / `verify` with anti-DoS cheap-before-expensive ordering (HMAC sig + expiry before backend eval).
|
|
118
|
+
- `Kiosk::Reputation::Factors` — immutable Data class with all-nullable reputation fields; `.empty` constructor.
|
|
119
|
+
- `Kiosk::Reputation::Policy` — base class (never challenge); providers subclass or replace.
|
|
120
|
+
- `Kiosk::Reputation::Policies::RateAndReputation` — shipped EXAMPLE policy: escalates by Equihash proof COUNT (count-curve; no continuous difficulty dial) on high request rate, zero purchases, and bad-proof history; providers are expected to replace it.
|
|
121
|
+
- RSpec suite covering: challenge round-trip, tampered fields, fingerprint mismatch, wrong secret, expiry ordering (spy backend proves backend NOT called before sig/expiry checks pass), wrong nonce, policy tier mapping, bad-proof escalation, backend registry.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
data/README.md
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# kiosk-reputation
|
|
2
|
+
|
|
3
|
+
Policy and wire-challenge layer for Kiosk's proof-of-work system.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`kiosk-reputation` decides *when* and *how hard* to challenge a request with a
|
|
8
|
+
proof-of-work, issues and verifies the signed wire challenge, and provides a
|
|
9
|
+
pluggable reputation policy interface.
|
|
10
|
+
|
|
11
|
+
**Pure Ruby — no dependency on kiosk-pow or kiosk-core.** A PoW backend registers
|
|
12
|
+
with `Kiosk::Reputation::Backends.register`. In a Rails app `kiosk-pow-equihash`
|
|
13
|
+
registers itself at boot; the host registers any other backend (see
|
|
14
|
+
[Installation](#installation)). This gem is backend-agnostic:
|
|
15
|
+
the shipped default is **equihash** (`kiosk-pow-equihash`); `kiosk-pow-argon2id`
|
|
16
|
+
provides Argon2id (legacy, registers as `argon2id`); `kiosk-pow-cuckoo` provides
|
|
17
|
+
Cuckatoo Cycle (opt-in, registers as `cuckatoo`). Each backend is built on
|
|
18
|
+
`kiosk-pow`, whose README tells how to add a backend.
|
|
19
|
+
|
|
20
|
+
## Wire protocol
|
|
21
|
+
|
|
22
|
+
### Challenge — HTTP 402 `pow_required`
|
|
23
|
+
|
|
24
|
+
When the provider's reputation policy decides to challenge a request, the server
|
|
25
|
+
responds with HTTP 402 instead of serving. The answer is an RFC 9457 problem
|
|
26
|
+
document (`application/problem+json`, accompanied by
|
|
27
|
+
`WWW-Authenticate: Kiosk-PoW realm="<issuer>"`) whose flat top-level `code` is
|
|
28
|
+
`pow_required` and which carries a **`challenges` array** (plural) as an
|
|
29
|
+
extension member — the policy demands `count` independent proofs (N×PoW). A
|
|
30
|
+
policy that asks for more than one proof gets one challenge per proof:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"type": "https://kiosk.tech/problems/pow_required",
|
|
35
|
+
"title": "Proof-of-work required",
|
|
36
|
+
"status": 402,
|
|
37
|
+
"detail": "proof-of-work required",
|
|
38
|
+
"code": "pow_required",
|
|
39
|
+
"challenges": [
|
|
40
|
+
{
|
|
41
|
+
"id": "<uuid>",
|
|
42
|
+
"alg": "equihash",
|
|
43
|
+
"params": { "n": 168, "k": 7 },
|
|
44
|
+
"salt": "<base64 of 16 raw bytes, fresh per challenge>",
|
|
45
|
+
"exp": 1750000600,
|
|
46
|
+
"sig": "<HMAC-SHA256 hex over canonical(id|alg|params|salt|exp|request_fingerprint)>"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Key properties:
|
|
53
|
+
|
|
54
|
+
- **`challenges` is a list of N independent challenges.** Equihash has no
|
|
55
|
+
continuous difficulty dial (memory is fixed by `(n, k)`) — the anti-abuse
|
|
56
|
+
lever is PROOF COUNT. Each challenge has its own `id` and fresh `salt`, so
|
|
57
|
+
there is no amortisation: all N must be solved to prove work for this request.
|
|
58
|
+
- `alg` + `params` are **mandated** — no client negotiation field. `equihash`
|
|
59
|
+
is the shipped default; `argon2id` is legacy.
|
|
60
|
+
- `salt` is fresh per challenge — prevents precomputation / rainbow tables.
|
|
61
|
+
- `sig` is HMAC-SHA256 (provider `pow_secret`) over the challenge fields **and
|
|
62
|
+
a fingerprint of the original request**
|
|
63
|
+
(`SHA256(METHOD + " " + verb + "\n" + canonical_json(args))` — see the retry
|
|
64
|
+
section below for why that formula matches on retry by construction).
|
|
65
|
+
Each challenge is stateless-verifiable (no server storage needed to trust it)
|
|
66
|
+
and bound to that specific request (a proof cannot be replayed against a
|
|
67
|
+
different call).
|
|
68
|
+
- `exp` bounds the replay window; when N > 1 the server scales the TTL with the
|
|
69
|
+
count so a slow client solving proofs sequentially does not race expiry.
|
|
70
|
+
|
|
71
|
+
### Proof — client re-sends the same request, proof(s) in the `Kiosk-PoW` header
|
|
72
|
+
|
|
73
|
+
The client solves each challenge and re-sends the SAME request — same method,
|
|
74
|
+
same path, same query string, same body — with the proof(s) in a **`Kiosk-PoW`
|
|
75
|
+
request header** as raw minified JSON, one `{challenge, nonce}` entry
|
|
76
|
+
per solved challenge. The wire makes most calls GETs (a query is
|
|
77
|
+
`GET <endpoint>/<query-name>`), and a GET has no body, so the header is the ONLY
|
|
78
|
+
channel a proof can travel on:
|
|
79
|
+
|
|
80
|
+
```http
|
|
81
|
+
GET /kiosk/menu_by_restaurant?restaurant=Mamma%20Pizza
|
|
82
|
+
Kiosk-PoW: [{"challenge":{ "...echoed verbatim...": true },"nonce":{"indices":[…]}}]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A single proof may be sent bare (`Kiosk-PoW: {"challenge":{…},"nonce":{…}}`), as a
|
|
86
|
+
JSON array of N proofs, or as repeated `Kiosk-PoW` header lines (one proof each) —
|
|
87
|
+
all flatten to one proofs list. Because the proof rides in the header, the
|
|
88
|
+
request is byte-identical at issue time (no proof) and verify time (proof in the
|
|
89
|
+
header), so the request fingerprint —
|
|
90
|
+
`SHA256(METHOD + " " + verb + "\n" + canonical_json(args))` — matches on retry by
|
|
91
|
+
construction; there is no `pow` body field to exclude. That formula binds a proof
|
|
92
|
+
to the exact call: the method, the verb as it appears in the path, and the
|
|
93
|
+
canonical arguments, so a proof solved for `GET /catalog?city=Lisbon` is
|
|
94
|
+
spendable on nothing else.
|
|
95
|
+
|
|
96
|
+
## Anti-DoS invariant: cheap checks before the expensive backend eval
|
|
97
|
+
|
|
98
|
+
`Challenge.verify` enforces this order — always:
|
|
99
|
+
|
|
100
|
+
1. Recompute + compare HMAC sig (constant-time `OpenSSL.fixed_length_secure_compare`)
|
|
101
|
+
→ `:bad_sig` (tampered, wrong request binding, or forged challenge)
|
|
102
|
+
2. Expiry check (`exp > now`) → `:expired`
|
|
103
|
+
3. Parameter re-derivation against the caller's live config (`expect:`)
|
|
104
|
+
→ `:bad_params` (see below)
|
|
105
|
+
4. **Only then**: one backend eval (equihash verify — 2^k BLAKE2b hashes, ~ms + KB)
|
|
106
|
+
→ `:ok` / `:bad_proof`
|
|
107
|
+
|
|
108
|
+
Floods of forged, expired or off-spec proofs are rejected at steps 1/2/3 without
|
|
109
|
+
burning a backend eval. This is not optional; it is tested.
|
|
110
|
+
|
|
111
|
+
## Server-side parameter re-derivation (`expect:`)
|
|
112
|
+
|
|
113
|
+
The challenge is stateless, so `alg`/`params` travel on the wire and come back
|
|
114
|
+
from the client. The HMAC sig proves *we minted them*; it does **not** prove they
|
|
115
|
+
are still the difficulty this server demands. Pass `expect:` — the spec the
|
|
116
|
+
caller just re-derived from its own configuration for this request — and
|
|
117
|
+
`verify` refuses any challenge naming other parameters, however valid its sig:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
Kiosk::Reputation::Challenge.verify(
|
|
121
|
+
challenge: challenge, nonce: solution,
|
|
122
|
+
request_fingerprint: fp, secret: secret, now: Time.now.to_i,
|
|
123
|
+
expect: { alg: "equihash", params: { n: 168, k: 7 } } # from YOUR config, not the wire
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Two things this buys: a challenge minted just before a difficulty raise stops
|
|
128
|
+
being solvable at the old cheap parameters for the rest of its TTL, and a leaked
|
|
129
|
+
HMAC secret cannot let a self-signed `{n: 8, k: 1}` challenge turn the toll
|
|
130
|
+
off — the toll degrades rather than vanishing. `kiosk-server`'s gate always
|
|
131
|
+
passes `expect:`; `Challenge.verify` skips the check when it is omitted.
|
|
132
|
+
|
|
133
|
+
Comparison uses the same canonical `k=v` rendering the sig covers, so key order,
|
|
134
|
+
Symbol-vs-String keys and `168` vs `"168"` are all equal — it rejects a different
|
|
135
|
+
difficulty and nothing else. `:bad_params` means **re-challenge, not bad faith**:
|
|
136
|
+
`kiosk-server` releases the challenge id and answers 402 at current params
|
|
137
|
+
(never 403).
|
|
138
|
+
|
|
139
|
+
## Components
|
|
140
|
+
|
|
141
|
+
### `Backends`
|
|
142
|
+
|
|
143
|
+
Registry mapping algorithm names to PoW backend objects.
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
Kiosk::Reputation::Backends.register("equihash", Kiosk::Pow::Equihash)
|
|
147
|
+
Kiosk::Reputation::Backends.fetch("equihash") # => Kiosk::Pow::Equihash
|
|
148
|
+
Kiosk::Reputation::Backends.known # => ["equihash"]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A registered backend need only respond to:
|
|
152
|
+
- `.verify(salt:, params:, nonce:) → Boolean` — verify a submitted proof
|
|
153
|
+
(`Challenge.verify` resolves the backend via `fetch` and calls only this).
|
|
154
|
+
|
|
155
|
+
Backends also expose a `.params` helper for building challenge params, but its
|
|
156
|
+
signature is algorithm-specific (equihash `(n:, k:)`, argon2id `(d:, m:, t:, p:)`,
|
|
157
|
+
cuckatoo `(edgebits:, proofsize:, target:)`) and it is called directly on the
|
|
158
|
+
concrete backend by the host, never through this registry.
|
|
159
|
+
|
|
160
|
+
### `Challenge`
|
|
161
|
+
|
|
162
|
+
Stateless, request-bound wire challenge. The caller (`kiosk-server`) maintains a
|
|
163
|
+
spent-id set (TTL ≤ `challenge[:exp]`) to prevent replay of a valid proof —
|
|
164
|
+
`Challenge` itself does not track spent ids.
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
challenge = Kiosk::Reputation::Challenge.issue(
|
|
168
|
+
alg: "equihash",
|
|
169
|
+
params: { n: 168, k: 7 },
|
|
170
|
+
request_fingerprint: "sha256:...",
|
|
171
|
+
secret: "provider-hmac-key",
|
|
172
|
+
ttl: 300 # seconds; exp = Time.now.to_i + ttl
|
|
173
|
+
)
|
|
174
|
+
# => { id:, alg:, params:, salt: <base64>, exp:, sig: }
|
|
175
|
+
|
|
176
|
+
result = Kiosk::Reputation::Challenge.verify(
|
|
177
|
+
challenge: challenge,
|
|
178
|
+
nonce: client_solution, # opaque to Challenge; passed to the backend .verify
|
|
179
|
+
request_fingerprint: "sha256:...",
|
|
180
|
+
secret: "provider-hmac-key",
|
|
181
|
+
now: Time.now.to_i,
|
|
182
|
+
expect: { alg: "equihash", params: { n: 168, k: 7 } } # optional; from live config
|
|
183
|
+
)
|
|
184
|
+
# => :ok | :bad_sig | :expired | :bad_params | :bad_proof
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### `Factors`
|
|
188
|
+
|
|
189
|
+
Immutable bundle of reputation inputs per request. Both fields may be nil.
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
Kiosk::Reputation::Factors.new(
|
|
193
|
+
settled_purchases_count: 3, # settlements and visits in the window
|
|
194
|
+
settled_purchases_cents: 65_00 # their value in cents, less refunds
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
Kiosk::Reputation::Factors.empty # both fields nil
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### `Policy` (base)
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
class MyPolicy < Kiosk::Reputation::Policy
|
|
204
|
+
# @param identity [Object] opaque identity value from the host
|
|
205
|
+
# @param verb [Symbol] the WIRE command — :query, :run or :pay. Not the
|
|
206
|
+
# declaration name: a verb a handler declares as `kind :action` in
|
|
207
|
+
# kiosk-server arrives here as :run (`Kiosk::Handler` serves an action
|
|
208
|
+
# as `:run`), so branch on :run rather than on :action. The protocol
|
|
209
|
+
# states the same mapping in its Reputation section. Branching on :action
|
|
210
|
+
# would otherwise be silent: it matches
|
|
211
|
+
# nothing, so the toll never applies to a write and no error, log line or
|
|
212
|
+
# test says so. kiosk-server does not let it be silent: assigning a
|
|
213
|
+
# policy whose #challenge_for compares the verb against :action raises
|
|
214
|
+
# Kiosk::Server::Errors::ConfigurationError from
|
|
215
|
+
# `Kiosk.configure`, at boot, naming :run.
|
|
216
|
+
# @param factors [Factors] reputation inputs
|
|
217
|
+
# @return [Hash{alg:, params:, count:}] challenge spec, or nil to serve without challenge
|
|
218
|
+
# (`count` is optional; the gate defaults it to 1 when omitted)
|
|
219
|
+
def challenge_for(identity:, verb:, factors:)
|
|
220
|
+
# example: always challenge unknown principals
|
|
221
|
+
return nil if factors.settled_purchases_count.to_i >= 5
|
|
222
|
+
{ alg: "equihash", params: { n: 168, k: 7 }, count: 1 }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The base `Policy` class returns `nil` for every request (never challenge).
|
|
228
|
+
|
|
229
|
+
### `Policies::RecentSpend` — one toll per session, by a chance that recent spend lowers
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
c.reputation_policy = Kiosk::Reputation::Policies::RecentSpend.new(within: 1.week, cap: 100)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
A session pays one proof at the Equihash gem's default params, with the chance
|
|
236
|
+
`max(0, (cap - spend) / cap)`. `spend` is what the customer spent here within
|
|
237
|
+
`within`, and `cap` is in whole currency units. So a customer with no spend
|
|
238
|
+
pays in every session, and a customer who spent `cap` or more pays in none.
|
|
239
|
+
The draw is made once for each customer in each token lifetime. Token
|
|
240
|
+
lifetimes are consecutive spans of `exp - iat` seconds from the Unix epoch, and
|
|
241
|
+
the token's `iat` selects its span. So every call made with one token, and every
|
|
242
|
+
new token issued to the customer in the same span, meets the same outcome.
|
|
243
|
+
An operator that takes no payment records each fulfilled visit with
|
|
244
|
+
`Kiosk::Visit.record!(user_id:, value_cents:)`, and the visit counts as spend.
|
|
245
|
+
A refund through `Kiosk::Server::PaymentClaim#refund` is not spend.
|
|
246
|
+
|
|
247
|
+
## kiosk-server configuration
|
|
248
|
+
|
|
249
|
+
The server gate (`Kiosk::Server::PowGate`) reads these config slots:
|
|
250
|
+
|
|
251
|
+
| Config slot | Type | Default | Description |
|
|
252
|
+
|-------------|------|---------|-------------|
|
|
253
|
+
| `reputation_policy` | `Kiosk::Reputation::Policy` instance (or duck type) | `nil` | Policy that decides when and how hard to challenge. `nil` = never challenge (zero overhead). |
|
|
254
|
+
| `pow_secret` | String | required when policy is set | HMAC key for challenge signing. Load from env: `ENV.fetch("KIOSK_POW_SECRET")`. |
|
|
255
|
+
| `pow_ttl` | Integer (seconds) | `300` | Challenge validity window. |
|
|
256
|
+
| `pow_spent_store` | `#claim`/`#release`/`#spent?`/`#mark_spent` | `Kiosk::Server::PowSpentStores::ActiveRecord` (the `kiosk.pow_spent` table) | Tracks spent challenge ids so a proof is accepted once, across every process and deploy. See kiosk-server's README, "Multi-process deployments". |
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
Kiosk.configure do |c|
|
|
260
|
+
c.reputation_policy = Kiosk::Reputation::Policies::RecentSpend.new(within: 1.week, cap: 100)
|
|
261
|
+
c.pow_secret = ENV.fetch("KIOSK_POW_SECRET")
|
|
262
|
+
c.pow_ttl = 300
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The engine supplies the factors. For a policy that answers `#within` (a window
|
|
267
|
+
in seconds), it fills `settled_purchases_count` and `settled_purchases_cents`
|
|
268
|
+
from the customer's own settlements and recorded visits in that window; for any
|
|
269
|
+
other policy the factors are `Factors.empty`.
|
|
270
|
+
|
|
271
|
+
The gate tolls once per access token: after one paid toll, every call made
|
|
272
|
+
with that token proceeds until the token expires (`c.access_token_ttl`,
|
|
273
|
+
default 3600 seconds).
|
|
274
|
+
|
|
275
|
+
## Honesty / guardrails
|
|
276
|
+
|
|
277
|
+
- **PoW is economic friction, not a wall.** A determined ASIC attacker still
|
|
278
|
+
gets in at some cost; the point is to make free-riding cost more than buying.
|
|
279
|
+
Combine it with a cheap path for genuine buyers: `RecentSpend` tolls a
|
|
280
|
+
customer less often as their recent spend grows.
|
|
281
|
+
- **Reputation = a sum of factors the host supplies.** We ship tools and one
|
|
282
|
+
policy, not a mandated curve. Providers are expected to write
|
|
283
|
+
domain-specific policies that reflect their actual trust signals.
|
|
284
|
+
- **The provider mandates the algorithm.** Clients comply or are denied — do
|
|
285
|
+
not add a negotiation path.
|
|
286
|
+
- **Verify is cheap; the solve is the cost.** Equihash verify is a few ms + KB
|
|
287
|
+
(memory is the asymmetry: a few KB to verify, against a solve the reference
|
|
288
|
+
numpy solver runs in ~1.3 GiB at n=168 k=7 — that is THAT solver's
|
|
289
|
+
sorted-nonce table, not a floor those params impose on every solver, since a
|
|
290
|
+
memory-optimised solver trades the table for time, which is how Equihash
|
|
291
|
+
200/9's real footprint fell to ~144 MB), so the gate can check every request
|
|
292
|
+
— only *challenge* on suspicion/rate, since each challenge costs the client a
|
|
293
|
+
real solve.
|
|
294
|
+
|
|
295
|
+
## Installation
|
|
296
|
+
|
|
297
|
+
> **Not on RubyGems yet** — so every `gem` line below carries `github: "kiosk-hq/kiosk"`, which is what makes it copy-pasteable today. Publication status and the canonical install are stated once, in the monorepo README's [Install](https://github.com/kiosk-hq/kiosk#install) section.
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
gem "kiosk-reputation", github: "kiosk-hq/kiosk"
|
|
301
|
+
gem "kiosk-pow-equihash", github: "kiosk-hq/kiosk" # default backend
|
|
302
|
+
# gem "kiosk-pow-argon2id", github: "kiosk-hq/kiosk" # legacy Argon2id backend, if you specifically want it
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
In a Rails app `kiosk-pow-equihash` registers itself as the `equihash`
|
|
306
|
+
backend at boot. Any other backend is registered with `Backends.register`.
|
|
307
|
+
|
|
308
|
+
## License
|
|
309
|
+
|
|
310
|
+
Apache-2.0. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Reputation
|
|
5
|
+
# Registry of PoW backends by algorithm name; a backend answers `.verify(salt:, params:, nonce:)`.
|
|
6
|
+
# A backend is registered before the first challenge verify. In a Rails app
|
|
7
|
+
# kiosk-pow-equihash registers `equihash` itself at boot.
|
|
8
|
+
module Backends
|
|
9
|
+
@registry = {}
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def register(alg_name, backend)
|
|
13
|
+
@registry[alg_name.to_s] = backend
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fetch(alg_name)
|
|
17
|
+
key = alg_name.to_s
|
|
18
|
+
@registry.fetch(key) do
|
|
19
|
+
raise KeyError,
|
|
20
|
+
"Unknown PoW backend: #{key.inspect}. " \
|
|
21
|
+
"Known algorithms: #{known.inspect}. " \
|
|
22
|
+
"Register a backend with Kiosk::Reputation::Backends.register(#{key.inspect}, backend)."
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Could a challenge minted at `params` ever verify? False only when the
|
|
27
|
+
# registered backend's own optional `.valid_params?` says so.
|
|
28
|
+
def valid_params?(alg_name, params)
|
|
29
|
+
backend = @registry[alg_name.to_s]
|
|
30
|
+
return true unless backend.respond_to?(:valid_params?)
|
|
31
|
+
|
|
32
|
+
backend.valid_params?(params)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def known
|
|
36
|
+
@registry.keys.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset!
|
|
40
|
+
@registry = {}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Kiosk
|
|
8
|
+
module Reputation
|
|
9
|
+
# Stateless wire challenge for a `pow_required` answer: an HMAC over its fields
|
|
10
|
+
# and the request fingerprint, so it needs no storage and binds to one request.
|
|
11
|
+
# The caller keeps the spent-id set.
|
|
12
|
+
module Challenge
|
|
13
|
+
# Canonical-string delimiters; no field may contain one (see {delimiter_offence}).
|
|
14
|
+
OUTER_DELIM = '|'
|
|
15
|
+
PARAM_DELIM = ','
|
|
16
|
+
KV_DELIM = '='
|
|
17
|
+
|
|
18
|
+
DELIMITERS = [OUTER_DELIM, PARAM_DELIM, KV_DELIM].freeze
|
|
19
|
+
|
|
20
|
+
# The signed fields, in canonical-string order. `salt` is base64.
|
|
21
|
+
Fields = Data.define(:id, :alg, :params, :salt, :exp, :request_fingerprint)
|
|
22
|
+
private_constant :Fields
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# @return [Hash] wire challenge: {id:, alg:, params:, salt: <base64>, exp:, sig:}
|
|
26
|
+
# @raise [ArgumentError] when a field carries a delimiter or `params` is not a Hash
|
|
27
|
+
def issue(alg:, params:, request_fingerprint:, secret:, ttl:, # rubocop:disable Metrics/ParameterLists -- public keyword signature; every keyword is part of it.
|
|
28
|
+
now: Time.now.to_i,
|
|
29
|
+
salt: SecureRandom.bytes(16),
|
|
30
|
+
id: SecureRandom.uuid)
|
|
31
|
+
fields = Fields.new(id:, alg:, params:, salt: Base64.strict_encode64(salt), exp: now + ttl, request_fingerprint:)
|
|
32
|
+
offence = delimiter_offence(fields)
|
|
33
|
+
raise ArgumentError, ambiguity_message(offence) if offence
|
|
34
|
+
|
|
35
|
+
{ id:, alg:, params:, salt: fields.salt, exp: fields.exp, sig: compute_sig(secret, fields) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Cheap checks run before the one expensive backend evaluation.
|
|
39
|
+
# `expect:` is the `{alg:, params:}` the caller re-derived from its live config.
|
|
40
|
+
# @return [Symbol] :ok | :bad_sig | :expired | :bad_params | :bad_proof
|
|
41
|
+
def verify(challenge:, nonce:, request_fingerprint:, secret:, now:, expect: nil) # rubocop:disable Metrics/ParameterLists -- public keyword signature; every keyword is part of it.
|
|
42
|
+
failure = check(challenge:, request_fingerprint:, secret:, now:, expect:)
|
|
43
|
+
return failure if failure
|
|
44
|
+
|
|
45
|
+
proof_valid?(fields_of(challenge, request_fingerprint), nonce) ? :ok : :bad_proof
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The cheap checks alone, so a caller can trust `id` and `exp` before it stores them.
|
|
49
|
+
# @return [Symbol, nil] :bad_sig | :expired | :bad_params, or nil when the challenge is ours and live
|
|
50
|
+
def check(challenge:, request_fingerprint:, secret:, now:, expect: nil)
|
|
51
|
+
cheap_failure(fields_of(challenge, request_fingerprint), challenge[:sig].to_s, secret, now, expect)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def fields_of(challenge, request_fingerprint)
|
|
57
|
+
Fields.new(id: challenge[:id], alg: challenge[:alg], params: challenge[:params],
|
|
58
|
+
salt: challenge[:salt], exp: challenge[:exp], request_fingerprint:)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The checks that cost no proof evaluation, in order. Nil when all pass.
|
|
62
|
+
def cheap_failure(fields, sig, secret, now, expect)
|
|
63
|
+
return :bad_sig unless constant_time_compare(compute_sig(secret, fields), sig)
|
|
64
|
+
return :expired unless fields.exp.to_i > now.to_i
|
|
65
|
+
# The signed string must have one pre-image.
|
|
66
|
+
return :bad_params if delimiter_offence(fields)
|
|
67
|
+
|
|
68
|
+
# The signature proves that we minted the challenge, not that we still demand it.
|
|
69
|
+
:bad_params unless matches_expected?(expect, fields.alg, fields.params)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The one expensive step: a single backend evaluation.
|
|
73
|
+
def proof_valid?(fields, nonce)
|
|
74
|
+
Backends.fetch(fields.alg).verify(salt: Base64.strict_decode64(fields.salt),
|
|
75
|
+
params: fields.params.transform_keys(&:to_sym), nonce:)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ambiguity_message(offence)
|
|
79
|
+
"challenge field would make the signed canonical string ambiguous: #{offence}. " \
|
|
80
|
+
'Fix the source of the value: the `alg`/`params` your reputation_policy returns ' \
|
|
81
|
+
'from #challenge_for.'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A delimiter inside a value gives the canonical string a second pre-image,
|
|
85
|
+
# so one signature would cover two (alg, params) splits.
|
|
86
|
+
def delimiter_offence(fields)
|
|
87
|
+
outer_offence(fields) || params_offence(fields.params)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def outer_offence(fields)
|
|
91
|
+
field, value = fields.to_h.except(:params).find { |_, text| text.to_s.include?(OUTER_DELIM) }
|
|
92
|
+
"#{field} #{value.to_s.inspect} contains #{OUTER_DELIM.inspect}" if field
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def params_offence(params)
|
|
96
|
+
return nil unless params.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
params.each do |key, value|
|
|
99
|
+
DELIMITERS.each do |delim|
|
|
100
|
+
return "params key #{key.to_s.inspect} contains #{delim.inspect}" if key.to_s.include?(delim)
|
|
101
|
+
return "params value #{value.to_s.inspect} (key #{key.to_s.inspect}) contains #{delim.inspect}" if value.to_s.include?(delim)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Compared in the signed rendering, so key order and key/value types never matter.
|
|
108
|
+
def matches_expected?(expect, alg, params)
|
|
109
|
+
return true if expect.nil?
|
|
110
|
+
|
|
111
|
+
expected_alg = expect[:alg] || expect['alg']
|
|
112
|
+
return false unless expected_alg.nil? || expected_alg.to_s == alg.to_s
|
|
113
|
+
|
|
114
|
+
expected_params = expect[:params] || expect['params']
|
|
115
|
+
return true if expected_params.nil?
|
|
116
|
+
|
|
117
|
+
params_string(expected_params) == params_string(params)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def compute_sig(secret, fields)
|
|
121
|
+
OpenSSL::HMAC.hexdigest('SHA256', secret, canonical_string(fields))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# id|alg|k=7,n=168|<salt_b64>|<exp>|<fingerprint>
|
|
125
|
+
def canonical_string(fields)
|
|
126
|
+
[fields.id, fields.alg, params_string(fields.params), fields.salt, fields.exp.to_s,
|
|
127
|
+
fields.request_fingerprint].join(OUTER_DELIM)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def params_string(params)
|
|
131
|
+
raise ArgumentError, "params must be a Hash (got #{params.inspect})" unless params.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
params
|
|
134
|
+
.sort_by { |k, _| k.to_s }
|
|
135
|
+
.map { |k, v| "#{k}#{KV_DELIM}#{v}" }
|
|
136
|
+
.join(PARAM_DELIM)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def constant_time_compare(expected, given)
|
|
140
|
+
return false if expected.bytesize != given.bytesize
|
|
141
|
+
|
|
142
|
+
OpenSSL.fixed_length_secure_compare(expected, given)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Reputation
|
|
5
|
+
# Reputation inputs per request. For a policy that answers `#within`, the
|
|
6
|
+
# engine fills both fields from its own settlements and recorded visits.
|
|
7
|
+
# Each field may be nil, so a policy reads them through `.to_i`.
|
|
8
|
+
Factors = Data.define(:settled_purchases_count, :settled_purchases_cents) do
|
|
9
|
+
def self.empty = new(settled_purchases_count: nil, settled_purchases_cents: nil)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Reputation
|
|
7
|
+
module Policies
|
|
8
|
+
# One proof-of-work toll per session, by chance. The chance falls linearly
|
|
9
|
+
# with what the customer spent here within `within` seconds: 1 with no
|
|
10
|
+
# spend, 0 at `cap` or more. The draw is made once per customer and token
|
|
11
|
+
# lifetime, so a new token minted in the same lifetime meets the same outcome.
|
|
12
|
+
class RecentSpend < Policy
|
|
13
|
+
attr_reader :within
|
|
14
|
+
|
|
15
|
+
# @param within [#to_i] the window in seconds, e.g. `1.week`
|
|
16
|
+
# @param cap [Integer] whole currency units of recent spend at which no session pays the toll
|
|
17
|
+
def initialize(within:, cap:)
|
|
18
|
+
super()
|
|
19
|
+
@within = within
|
|
20
|
+
@cap_cents = cap * 100
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def challenge_for(identity:, factors:, **)
|
|
24
|
+
{ alg: 'equihash', params: equihash_params, count: 1 } if draw(identity) < chance(factors.settled_purchases_cents.to_i)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The chance that a session pays the toll: max(0, (cap - spend) / cap).
|
|
28
|
+
def chance(cents) = [(@cap_cents - cents).fdiv(@cap_cents), 0].max
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# A number in [0, 1), set by the customer and the token lifetime that holds
|
|
33
|
+
# the token's `iat`: lifetimes are consecutive spans of `exp - iat` seconds
|
|
34
|
+
# from the Unix epoch. A caller whose token has no `iat` or `exp` draws 0,
|
|
35
|
+
# so it pays whenever its spend leaves a chance above 0.
|
|
36
|
+
def draw(identity)
|
|
37
|
+
claims = identity&.claims || {}
|
|
38
|
+
return 0 unless claims[:iat] && claims[:exp]
|
|
39
|
+
|
|
40
|
+
span = claims[:iat].div(claims[:exp] - claims[:iat])
|
|
41
|
+
Digest::SHA256.digest("#{identity.user_id}:#{span}").unpack1('Q>').fdiv(2**64)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The Equihash gem's default params when it is loaded, so a test that shrinks them shrinks this toll too.
|
|
45
|
+
def equihash_params = defined?(::Kiosk::Pow::Equihash) ? ::Kiosk::Pow::Equihash.params : { n: 168, k: 7 }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Reputation
|
|
5
|
+
# Base policy: never challenges. Providers subclass it.
|
|
6
|
+
class Policy
|
|
7
|
+
# The gate calls it with `identity:`, `verb:` and `factors:`. `verb` is the
|
|
8
|
+
# coarse CALL KIND: :query, :run or :pay (a `kind :action` handler arrives as :run).
|
|
9
|
+
# @return [Hash{alg: String, params: Hash, count: Integer}, nil] nil serves without challenge; `count` defaults to 1
|
|
10
|
+
def challenge_for(**)
|
|
11
|
+
nil
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kiosk/reputation/version'
|
|
4
|
+
require 'kiosk/reputation/backends'
|
|
5
|
+
require 'kiosk/reputation/challenge'
|
|
6
|
+
require 'kiosk/reputation/factors'
|
|
7
|
+
require 'kiosk/reputation/policy'
|
|
8
|
+
require 'kiosk/reputation/policies/recent_spend'
|
|
9
|
+
|
|
10
|
+
module Kiosk
|
|
11
|
+
# Policy and wire-challenge layer for proof of work, independent of any PoW
|
|
12
|
+
# algorithm. {Challenge} is stateless: the caller keeps the spent-id set.
|
|
13
|
+
module Reputation
|
|
14
|
+
end
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: kiosk-reputation
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.5.20
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Phil Pirozhkov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: |
|
|
27
|
+
kiosk-reputation decides *when* and *how hard* to challenge a request with a
|
|
28
|
+
proof-of-work, issues and verifies the signed wire challenge, and provides a
|
|
29
|
+
pluggable reputation policy interface.
|
|
30
|
+
|
|
31
|
+
Pure Ruby — no dependency on any PoW gem or kiosk-core. A PoW backend is
|
|
32
|
+
registered via Kiosk::Reputation::Backends.register: kiosk-pow-equihash
|
|
33
|
+
registers itself as "equihash" (the shipped default) in a Rails app; the host
|
|
34
|
+
registers kiosk-pow-argon2id as the legacy "argon2id" and kiosk-pow-cuckoo as
|
|
35
|
+
the opt-in "cuckatoo".
|
|
36
|
+
|
|
37
|
+
Anti-DoS invariant: Challenge.verify performs cheap HMAC-sig + expiry checks
|
|
38
|
+
BEFORE the one expensive backend eval, so floods of forged/expired proofs are
|
|
39
|
+
rejected without running a proof check.
|
|
40
|
+
|
|
41
|
+
Ships one policy, Policies::RecentSpend: it asks one proof of a session,
|
|
42
|
+
by a chance that the customer's recent spend lowers.
|
|
43
|
+
email:
|
|
44
|
+
- hello@fili.pp.ru
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- LICENSE.txt
|
|
51
|
+
- README.md
|
|
52
|
+
- lib/kiosk/reputation.rb
|
|
53
|
+
- lib/kiosk/reputation/backends.rb
|
|
54
|
+
- lib/kiosk/reputation/challenge.rb
|
|
55
|
+
- lib/kiosk/reputation/factors.rb
|
|
56
|
+
- lib/kiosk/reputation/policies/recent_spend.rb
|
|
57
|
+
- lib/kiosk/reputation/policy.rb
|
|
58
|
+
- lib/kiosk/reputation/version.rb
|
|
59
|
+
homepage: https://kiosk.tech
|
|
60
|
+
licenses:
|
|
61
|
+
- Apache-2.0
|
|
62
|
+
metadata:
|
|
63
|
+
homepage_uri: https://kiosk.tech
|
|
64
|
+
source_code_uri: https://github.com/kiosk-hq/kiosk
|
|
65
|
+
changelog_uri: https://github.com/kiosk-hq/kiosk/blob/main/kiosk-reputation/CHANGELOG.md
|
|
66
|
+
bug_tracker_uri: https://github.com/kiosk-hq/kiosk/issues
|
|
67
|
+
rubygems_mfa_required: 'true'
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '4.0'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 4.0.3
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Policy + wire-challenge layer for Kiosk's proof-of-work system
|
|
85
|
+
test_files: []
|