hecks 3.11.0 → 3.13.0
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/lib/hecks/adapters/driven/script_runner.adapter +3 -0
- data/lib/hecks/adapters/driven/script_runner.rb +93 -0
- data/lib/hecks/adapters/driven.rb +1 -0
- data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
- data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
- data/lib/hecks/adapters/driving/newcomer.rb +50 -0
- data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -15
- data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
- data/lib/hecks/bluebook/meta_validator.rb +28 -21
- data/lib/hecks/bluebook.rb +1 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +17 -3
- data/lib/hecks/framework/bluebook/change.behaviors +30 -0
- data/lib/hecks/framework/bluebook/change.bluebook +162 -0
- data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
- data/lib/hecks/framework/change.oidc.json +34 -0
- data/lib/hecks/framework/console_settings.oidc.json +103 -0
- data/lib/hecks/framework/governance.oidc.json +28 -0
- data/lib/hecks/framework/identity.oidc.json +18 -0
- data/lib/hecks/framework/privacy.oidc.json +30 -0
- data/lib/hecks/gate/stages.yml +35 -0
- data/lib/hecks/grammar/translation.oidc.json +48 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
- data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
- data/lib/hecks/hecks/hecks.world +3 -0
- data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
- data/lib/hecks/language/bluebook/policy.behaviors +19 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
- data/lib/hecks/projection_files/expression_tables.rb +7 -2
- data/lib/hecks/projections/deploy/box/compose.rb +6 -4
- data/lib/hecks/projections/deploy/box/edge.rb +62 -0
- data/lib/hecks/projections/deploy/box/logging.rb +99 -0
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
- data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
- data/lib/hecks/projections/deploy/box/settings.rb +6 -2
- data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
- data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
- data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
- data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
- data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box.rb +15 -3
- data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
- data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
- data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
- data/lib/hecks/projections/site/edge/checks.rb +2 -0
- data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
- data/lib/hecks/projections/site/edge/function_code.rb +86 -0
- data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
- data/lib/hecks/projections/site/edge.rb +35 -9
- data/lib/hecks/projections/site/site_cdn.rb +21 -7
- data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
- data/lib/hecks/projections/site/table/row_builder.rb +12 -2
- data/lib/hecks/projections/site/table.rb +4 -2
- data/lib/hecks/projector/cli_audience.rb +20 -0
- data/lib/hecks/projector/cli_projector/usage.rb +15 -4
- data/lib/hecks/rust_build/host.rb +11 -4
- data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
- data/lib/hecks/site/bluebook/site.bluebook +16 -1
- data/lib/hecks/tools/ci_gate_decision.rb +35 -0
- data/lib/hecks/tools/ci_gates.rb +20 -1
- data/lib/hecks/tools/lanes/files.rb +8 -0
- data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
- data/lib/hecks/tools/lanes.rb +1 -0
- data/lib/hecks/tools/oidc_manifests.rb +44 -6
- data/lib/hecks/tools/release_only.rb +111 -0
- data/lib/hecks/tools/site_routes/templates.rb +13 -9
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +15 -1
- data/prebuilt/2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal +0 -0
- data/prebuilt/58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal +0 -0
- data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json} +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/auth.rs +58 -0
- data/rust/host/src/extension.rs +13 -1
- data/rust/host/src/lib.rs +1 -0
- data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
- data/rust/host/src/platform_signin/config.rs +512 -0
- data/rust/host/src/platform_signin/cookie.rs +237 -0
- data/rust/host/src/platform_signin/extension.rs +190 -0
- data/rust/host/src/platform_signin/fetcher.rs +322 -0
- data/rust/host/src/platform_signin/handler.rs +251 -0
- data/rust/host/src/platform_signin/handler_tests.rs +554 -0
- data/rust/host/src/platform_signin/jti.rs +196 -0
- data/rust/host/src/platform_signin/jwks.rs +542 -0
- data/rust/host/src/platform_signin/jws.rs +249 -0
- data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
- data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
- data/rust/host/src/platform_signin/mod.rs +75 -0
- data/rust/host/src/platform_signin/mode_tests.rs +382 -0
- data/rust/host/src/platform_signin/pages.rs +204 -0
- data/rust/host/src/platform_signin/query.rs +233 -0
- data/rust/host/src/platform_signin/session.rs +151 -0
- data/rust/host/src/platform_signin/settings.rs +385 -0
- data/rust/host/src/platform_signin/strict_json.rs +351 -0
- data/rust/host/src/platform_signin/test_support.rs +235 -0
- data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
- data/rust/host/src/platform_signin/verify.rs +604 -0
- data/rust/host/src/platform_signin/wiring_support.rs +319 -0
- data/rust/host/src/web.rs +50 -6
- data/rust/parser/src/parse/value_object.rs +22 -10
- metadata +59 -7
- data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
- data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
- /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
- /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +0 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
//! A JSON reader that accepts only what two parsers could not disagree about.
|
|
2
|
+
//!
|
|
3
|
+
//! The standard parsers keep the last of a repeated key and accept deep nesting. A token whose
|
|
4
|
+
//! header or claims repeat a key could be read one way here and another way by the issuer, so this
|
|
5
|
+
//! reader refuses it. It also keeps numbers apart from each other (an integer is not a float)
|
|
6
|
+
//! because
|
|
7
|
+
//! the time claims must be integers.
|
|
8
|
+
|
|
9
|
+
use std::collections::HashMap;
|
|
10
|
+
|
|
11
|
+
/// A parsed JSON value, reduced to the shapes a token check needs to tell apart.
|
|
12
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
13
|
+
pub enum Json {
|
|
14
|
+
Null,
|
|
15
|
+
Bool(bool),
|
|
16
|
+
/// An integer; a digit string beyond the `i128` range saturates, which keeps every comparison
|
|
17
|
+
/// against a time window the same as an unbounded integer would give.
|
|
18
|
+
Int(i128),
|
|
19
|
+
/// Any number that is not an integer literal (a fraction or an exponent).
|
|
20
|
+
Number,
|
|
21
|
+
Str(String),
|
|
22
|
+
Array(Vec<Json>),
|
|
23
|
+
Object(JsonObject),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// A JSON object with unique keys.
|
|
27
|
+
#[derive(Debug, Clone, PartialEq, Default)]
|
|
28
|
+
pub struct JsonObject(HashMap<String, Json>);
|
|
29
|
+
|
|
30
|
+
impl JsonObject {
|
|
31
|
+
/// Finds a member by name.
|
|
32
|
+
pub fn get(&self, name: &str) -> Option<&Json> {
|
|
33
|
+
self.0.get(name)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Finds a member that is a string.
|
|
37
|
+
pub fn get_str(&self, name: &str) -> Option<&str> {
|
|
38
|
+
match self.get(name) {
|
|
39
|
+
Some(Json::Str(text)) => Some(text),
|
|
40
|
+
_ => None,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Tells whether the member exists and is not JSON `null`.
|
|
45
|
+
pub fn is_present(&self, name: &str) -> bool {
|
|
46
|
+
!matches!(self.get(name), None | Some(Json::Null))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Lists the member names, in no particular order.
|
|
50
|
+
pub fn keys(&self) -> impl Iterator<Item = &String> {
|
|
51
|
+
self.0.keys()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The deepest nesting accepted, counting the outer object as depth 1.
|
|
56
|
+
pub const MAX_DEPTH: usize = 8;
|
|
57
|
+
|
|
58
|
+
/// Parses `bytes` as one JSON object and nothing else.
|
|
59
|
+
///
|
|
60
|
+
/// Returns `None` for invalid UTF-8, invalid JSON, trailing text, a value that is not an object,
|
|
61
|
+
/// an object that repeats a key at any depth, and nesting deeper than [`MAX_DEPTH`].
|
|
62
|
+
pub fn parse_object(bytes: &[u8]) -> Option<JsonObject> {
|
|
63
|
+
let text = std::str::from_utf8(bytes).ok()?;
|
|
64
|
+
let mut parser = Parser {
|
|
65
|
+
bytes: text.as_bytes(),
|
|
66
|
+
at: 0,
|
|
67
|
+
};
|
|
68
|
+
parser.skip_whitespace();
|
|
69
|
+
let object = parser.object(0)?;
|
|
70
|
+
parser.skip_whitespace();
|
|
71
|
+
(parser.at == parser.bytes.len()).then_some(object)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// A recursive-descent reader over validated UTF-8; every method returns `None` at the first fault.
|
|
75
|
+
struct Parser<'a> {
|
|
76
|
+
bytes: &'a [u8],
|
|
77
|
+
at: usize,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
impl Parser<'_> {
|
|
81
|
+
fn peek(&self) -> Option<u8> {
|
|
82
|
+
self.bytes.get(self.at).copied()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fn skip_whitespace(&mut self) {
|
|
86
|
+
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
|
|
87
|
+
self.at += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fn expect(&mut self, byte: u8) -> Option<()> {
|
|
92
|
+
(self.peek() == Some(byte)).then(|| self.at += 1)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fn value(&mut self, depth: usize) -> Option<Json> {
|
|
96
|
+
match self.peek()? {
|
|
97
|
+
b'{' => self.object(depth).map(Json::Object),
|
|
98
|
+
b'[' => self.array(depth),
|
|
99
|
+
b'"' => self.string().map(Json::Str),
|
|
100
|
+
b't' => self.literal(b"true", Json::Bool(true)),
|
|
101
|
+
b'f' => self.literal(b"false", Json::Bool(false)),
|
|
102
|
+
b'n' => self.literal(b"null", Json::Null),
|
|
103
|
+
b'-' | b'0'..=b'9' => self.number(),
|
|
104
|
+
_ => None,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fn literal(&mut self, word: &[u8], value: Json) -> Option<Json> {
|
|
109
|
+
let end = self.at + word.len();
|
|
110
|
+
(self.bytes.get(self.at..end)? == word).then(|| {
|
|
111
|
+
self.at = end;
|
|
112
|
+
value
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn object(&mut self, depth: usize) -> Option<JsonObject> {
|
|
117
|
+
let depth = depth + 1;
|
|
118
|
+
if depth > MAX_DEPTH {
|
|
119
|
+
return None;
|
|
120
|
+
}
|
|
121
|
+
self.expect(b'{')?;
|
|
122
|
+
let mut members = HashMap::new();
|
|
123
|
+
self.skip_whitespace();
|
|
124
|
+
if self.peek() == Some(b'}') {
|
|
125
|
+
self.at += 1;
|
|
126
|
+
return Some(JsonObject(members));
|
|
127
|
+
}
|
|
128
|
+
loop {
|
|
129
|
+
self.skip_whitespace();
|
|
130
|
+
let key = self.string()?;
|
|
131
|
+
self.skip_whitespace();
|
|
132
|
+
self.expect(b':')?;
|
|
133
|
+
self.skip_whitespace();
|
|
134
|
+
let value = self.value(depth)?;
|
|
135
|
+
if members.insert(key, value).is_some() {
|
|
136
|
+
return None;
|
|
137
|
+
}
|
|
138
|
+
self.skip_whitespace();
|
|
139
|
+
match self.peek()? {
|
|
140
|
+
b',' => self.at += 1,
|
|
141
|
+
b'}' => {
|
|
142
|
+
self.at += 1;
|
|
143
|
+
return Some(JsonObject(members));
|
|
144
|
+
}
|
|
145
|
+
_ => return None,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fn array(&mut self, depth: usize) -> Option<Json> {
|
|
151
|
+
let depth = depth + 1;
|
|
152
|
+
if depth > MAX_DEPTH {
|
|
153
|
+
return None;
|
|
154
|
+
}
|
|
155
|
+
self.expect(b'[')?;
|
|
156
|
+
let mut items = Vec::new();
|
|
157
|
+
self.skip_whitespace();
|
|
158
|
+
if self.peek() == Some(b']') {
|
|
159
|
+
self.at += 1;
|
|
160
|
+
return Some(Json::Array(items));
|
|
161
|
+
}
|
|
162
|
+
loop {
|
|
163
|
+
self.skip_whitespace();
|
|
164
|
+
items.push(self.value(depth)?);
|
|
165
|
+
self.skip_whitespace();
|
|
166
|
+
match self.peek()? {
|
|
167
|
+
b',' => self.at += 1,
|
|
168
|
+
b']' => {
|
|
169
|
+
self.at += 1;
|
|
170
|
+
return Some(Json::Array(items));
|
|
171
|
+
}
|
|
172
|
+
_ => return None,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fn string(&mut self) -> Option<String> {
|
|
178
|
+
self.expect(b'"')?;
|
|
179
|
+
let mut out: Vec<u8> = Vec::new();
|
|
180
|
+
loop {
|
|
181
|
+
let byte = self.peek()?;
|
|
182
|
+
self.at += 1;
|
|
183
|
+
match byte {
|
|
184
|
+
b'"' => return String::from_utf8(out).ok(),
|
|
185
|
+
b'\\' => self.escape(&mut out)?,
|
|
186
|
+
0x00..=0x1f => return None,
|
|
187
|
+
other => out.push(other),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fn escape(&mut self, out: &mut Vec<u8>) -> Option<()> {
|
|
193
|
+
let byte = self.peek()?;
|
|
194
|
+
self.at += 1;
|
|
195
|
+
let simple = match byte {
|
|
196
|
+
b'"' => b'"',
|
|
197
|
+
b'\\' => b'\\',
|
|
198
|
+
b'/' => b'/',
|
|
199
|
+
b'b' => 0x08,
|
|
200
|
+
b'f' => 0x0c,
|
|
201
|
+
b'n' => b'\n',
|
|
202
|
+
b'r' => b'\r',
|
|
203
|
+
b't' => b'\t',
|
|
204
|
+
b'u' => return self.unicode_escape(out),
|
|
205
|
+
_ => return None,
|
|
206
|
+
};
|
|
207
|
+
out.push(simple);
|
|
208
|
+
Some(())
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
fn unicode_escape(&mut self, out: &mut Vec<u8>) -> Option<()> {
|
|
212
|
+
let first = self.hex4()?;
|
|
213
|
+
let code = match first {
|
|
214
|
+
0xD800..=0xDBFF => {
|
|
215
|
+
self.expect(b'\\')?;
|
|
216
|
+
self.expect(b'u')?;
|
|
217
|
+
let second = self.hex4()?;
|
|
218
|
+
if !(0xDC00..=0xDFFF).contains(&second) {
|
|
219
|
+
return None;
|
|
220
|
+
}
|
|
221
|
+
0x10000 + ((first - 0xD800) << 10) + (second - 0xDC00)
|
|
222
|
+
}
|
|
223
|
+
0xDC00..=0xDFFF => return None,
|
|
224
|
+
plain => plain,
|
|
225
|
+
};
|
|
226
|
+
let mut buffer = [0u8; 4];
|
|
227
|
+
out.extend_from_slice(char::from_u32(code)?.encode_utf8(&mut buffer).as_bytes());
|
|
228
|
+
Some(())
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
fn hex4(&mut self) -> Option<u32> {
|
|
232
|
+
let digits = self.bytes.get(self.at..self.at + 4)?;
|
|
233
|
+
let text = std::str::from_utf8(digits).ok()?;
|
|
234
|
+
if !text.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
235
|
+
return None;
|
|
236
|
+
}
|
|
237
|
+
self.at += 4;
|
|
238
|
+
u32::from_str_radix(text, 16).ok()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
fn digits(&mut self) -> Option<usize> {
|
|
242
|
+
let start = self.at;
|
|
243
|
+
while matches!(self.peek(), Some(b'0'..=b'9')) {
|
|
244
|
+
self.at += 1;
|
|
245
|
+
}
|
|
246
|
+
(self.at > start).then_some(self.at - start)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
fn number(&mut self) -> Option<Json> {
|
|
250
|
+
let negative = self.peek() == Some(b'-');
|
|
251
|
+
if negative {
|
|
252
|
+
self.at += 1;
|
|
253
|
+
}
|
|
254
|
+
let integer_start = self.at;
|
|
255
|
+
if self.peek() == Some(b'0') {
|
|
256
|
+
self.at += 1;
|
|
257
|
+
} else {
|
|
258
|
+
self.digits()?;
|
|
259
|
+
}
|
|
260
|
+
let integer_end = self.at;
|
|
261
|
+
let mut integral = true;
|
|
262
|
+
if self.peek() == Some(b'.') {
|
|
263
|
+
self.at += 1;
|
|
264
|
+
self.digits()?;
|
|
265
|
+
integral = false;
|
|
266
|
+
}
|
|
267
|
+
if matches!(self.peek(), Some(b'e' | b'E')) {
|
|
268
|
+
self.at += 1;
|
|
269
|
+
if matches!(self.peek(), Some(b'+' | b'-')) {
|
|
270
|
+
self.at += 1;
|
|
271
|
+
}
|
|
272
|
+
self.digits()?;
|
|
273
|
+
integral = false;
|
|
274
|
+
}
|
|
275
|
+
if !integral {
|
|
276
|
+
return Some(Json::Number);
|
|
277
|
+
}
|
|
278
|
+
let text = std::str::from_utf8(&self.bytes[integer_start..integer_end]).ok()?;
|
|
279
|
+
Some(Json::Int(integer_from(text, negative)))
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Reads an integer literal's digits, saturating at the `i128` limits instead of failing.
|
|
284
|
+
fn integer_from(digits: &str, negative: bool) -> i128 {
|
|
285
|
+
match digits.parse::<i128>() {
|
|
286
|
+
Ok(value) if negative => -value,
|
|
287
|
+
Ok(value) => value,
|
|
288
|
+
Err(_) if negative => i128::MIN,
|
|
289
|
+
Err(_) => i128::MAX,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#[cfg(test)]
|
|
294
|
+
mod tests {
|
|
295
|
+
use super::*;
|
|
296
|
+
|
|
297
|
+
fn parse(text: &str) -> Option<JsonObject> {
|
|
298
|
+
parse_object(text.as_bytes())
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#[test]
|
|
302
|
+
fn an_object_parses_and_its_members_are_readable() {
|
|
303
|
+
let object = parse(r#"{"a":"x","b":7,"c":true,"d":null}"#).expect("object");
|
|
304
|
+
assert_eq!(object.get_str("a"), Some("x"));
|
|
305
|
+
assert_eq!(object.get("b"), Some(&Json::Int(7)));
|
|
306
|
+
assert_eq!(object.get("c"), Some(&Json::Bool(true)));
|
|
307
|
+
assert!(!object.is_present("d"));
|
|
308
|
+
assert!(!object.is_present("missing"));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#[test]
|
|
312
|
+
fn a_repeated_key_is_refused_at_the_top_and_when_nested() {
|
|
313
|
+
assert!(parse(r#"{"a":1,"a":2}"#).is_none());
|
|
314
|
+
assert!(parse(r#"{"a":{"b":1,"b":1}}"#).is_none());
|
|
315
|
+
assert!(parse(r#"{"a":[{"b":1,"b":1}]}"#).is_none());
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#[test]
|
|
319
|
+
fn only_an_object_is_accepted() {
|
|
320
|
+
for text in [
|
|
321
|
+
"[]", "\"s\"", "1", "true", "null", "", " ", "{", "{}x", "{} {}",
|
|
322
|
+
] {
|
|
323
|
+
assert!(parse(text).is_none(), "{text:?} must be refused");
|
|
324
|
+
}
|
|
325
|
+
assert!(parse("{}").is_some());
|
|
326
|
+
assert!(parse(" {} ").is_some());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn invalid_utf8_is_refused() {
|
|
331
|
+
assert!(parse_object(b"{\"a\":\"\xff\"}").is_none());
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[test]
|
|
335
|
+
fn integers_and_other_numbers_are_told_apart() {
|
|
336
|
+
let object = parse(r#"{"i":-5,"f":1.5,"e":1e2,"z":1.0,"big":99999999999999999999999999999999999999999999}"#).expect("object");
|
|
337
|
+
assert_eq!(object.get("i"), Some(&Json::Int(-5)));
|
|
338
|
+
assert_eq!(object.get("f"), Some(&Json::Number));
|
|
339
|
+
assert_eq!(object.get("e"), Some(&Json::Number));
|
|
340
|
+
assert_eq!(object.get("z"), Some(&Json::Number));
|
|
341
|
+
assert_eq!(object.get("big"), Some(&Json::Int(i128::MAX)));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#[test]
|
|
345
|
+
fn nesting_is_capped_at_eight_levels() {
|
|
346
|
+
let ok = format!(r#"{{"a":{}{}}}"#, "[".repeat(7), "]".repeat(7));
|
|
347
|
+
assert!(parse(&ok).is_some(), "depth 8 is allowed");
|
|
348
|
+
let deep = format!(r#"{{"a":{}{}}}"#, "[".repeat(8), "]".repeat(8));
|
|
349
|
+
assert!(parse(&deep).is_none(), "depth 9 is refused");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
//! Shared scaffolding for this module's tests: throwaway keys, a signer, and a scenario builder.
|
|
2
|
+
//!
|
|
3
|
+
//! The seeds are the public throwaway seeds of the known-answer vectors; they are used for nothing
|
|
4
|
+
//! else and protect nothing. Tokens are signed with `jsonwebtoken`'s own signer so the tests do not
|
|
5
|
+
//! need a second crypto dependency.
|
|
6
|
+
|
|
7
|
+
use std::collections::HashMap;
|
|
8
|
+
|
|
9
|
+
use jsonwebtoken::{Algorithm, EncodingKey};
|
|
10
|
+
use serde_json::{json, Map, Value};
|
|
11
|
+
|
|
12
|
+
use super::config::PlatformSigninConfig;
|
|
13
|
+
use super::jti::JtiStore;
|
|
14
|
+
use super::jwks::{thumbprint, KeySource, VerifyingKey};
|
|
15
|
+
use super::jws::encode_base64url;
|
|
16
|
+
use super::strict_json::{Json, JsonObject};
|
|
17
|
+
use super::verify::{Refusal, SigninCookie, SigninRequest, VerifiedClaims, Verifier};
|
|
18
|
+
|
|
19
|
+
pub const NOW: i64 = 1_800_000_000;
|
|
20
|
+
pub const ISSUER: &str = "https://issuer.example";
|
|
21
|
+
pub const SITE: &str = "site-a";
|
|
22
|
+
pub const ENVIRONMENT: &str = "production";
|
|
23
|
+
pub const EMAIL: &str = "person@example.test";
|
|
24
|
+
pub const NONCE: &str = "Nn0nceNn0nceNn0nceAA";
|
|
25
|
+
pub const STATE: &str = "StateStateState0123456789";
|
|
26
|
+
|
|
27
|
+
pub const X_A: &str = "BrQXFhWGK4Fmi8rRsnn1KzomYanZaWCsFWaS0Dgv62k";
|
|
28
|
+
pub const KID_A: &str = "SwpkMw_CknhH156gpWgzPJOQNEGnS25LhD1OqeNsQko";
|
|
29
|
+
pub const X_B: &str = "y4eb9h3HL-Ojhu6bp87lvE1Nl6b8YjIqM_AGXTRMeB0";
|
|
30
|
+
|
|
31
|
+
/// A throwaway Ed25519 key pair: its seed and its public `x`.
|
|
32
|
+
#[derive(Clone, Copy)]
|
|
33
|
+
pub struct TestKey {
|
|
34
|
+
seed: &'static [u8; 32],
|
|
35
|
+
pub x: &'static str,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub const KEY_A: TestKey = TestKey {
|
|
39
|
+
seed: b"TEST-ONLY-KNOWN-ANSWER-SEED-0001",
|
|
40
|
+
x: X_A,
|
|
41
|
+
};
|
|
42
|
+
pub const KEY_B: TestKey = TestKey {
|
|
43
|
+
seed: b"TEST-ONLY-KNOWN-ANSWER-SEED-0002",
|
|
44
|
+
x: X_B,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
impl TestKey {
|
|
48
|
+
/// Returns the key's thumbprint, which is its `kid`.
|
|
49
|
+
pub fn kid(&self) -> String {
|
|
50
|
+
thumbprint(self.x)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Returns the public half as a verifying key.
|
|
54
|
+
pub fn verifying(&self) -> VerifyingKey {
|
|
55
|
+
VerifyingKey::from_x(self.x).expect("a valid public key")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Signs `input` as EdDSA and returns the unpadded base64url signature.
|
|
59
|
+
pub fn sign(&self, input: &str) -> String {
|
|
60
|
+
let mut der = vec![
|
|
61
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
|
|
62
|
+
0x04, 0x20,
|
|
63
|
+
];
|
|
64
|
+
der.extend_from_slice(self.seed);
|
|
65
|
+
let key = EncodingKey::from_ed_der(&der);
|
|
66
|
+
jsonwebtoken::crypto::sign(input.as_bytes(), &key, Algorithm::EdDSA).expect("signs")
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Builds `header.claims.signature` from raw JSON texts, signed by `key`.
|
|
71
|
+
pub fn assemble(header_text: &str, claims_text: &str, key: &TestKey) -> String {
|
|
72
|
+
let input = format!(
|
|
73
|
+
"{}.{}",
|
|
74
|
+
encode_base64url(header_text.as_bytes()),
|
|
75
|
+
encode_base64url(claims_text.as_bytes())
|
|
76
|
+
);
|
|
77
|
+
format!("{input}.{}", key.sign(&input))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// A key source that holds a fixed set of keys, by computed thumbprint.
|
|
81
|
+
pub struct StaticKeys(pub HashMap<String, VerifyingKey>);
|
|
82
|
+
|
|
83
|
+
impl KeySource for StaticKeys {
|
|
84
|
+
fn key_for(&self, kid: &str, _now: i64) -> Option<VerifyingKey> {
|
|
85
|
+
self.0.get(kid).cloned()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Builds a key source holding exactly `keys`.
|
|
90
|
+
pub fn static_keys(keys: &[&TestKey]) -> StaticKeys {
|
|
91
|
+
StaticKeys(keys.iter().map(|k| (k.kid(), k.verifying())).collect())
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Returns the pinned configuration the vectors use.
|
|
95
|
+
pub fn config() -> PlatformSigninConfig {
|
|
96
|
+
PlatformSigninConfig::new(
|
|
97
|
+
ISSUER,
|
|
98
|
+
"https://issuer.example/.well-known/jwks.json",
|
|
99
|
+
SITE,
|
|
100
|
+
ENVIRONMENT,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Builds a verifier that trusts exactly `keys`, with an empty single-use record.
|
|
105
|
+
pub fn verifier_for(keys: &[&TestKey]) -> Verifier<StaticKeys> {
|
|
106
|
+
Verifier::new(config(), static_keys(keys), JtiStore::new())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Returns the sign-in cookie that matches a good scenario.
|
|
110
|
+
pub fn good_cookie() -> SigninCookie {
|
|
111
|
+
SigninCookie {
|
|
112
|
+
nonce: NONCE.into(),
|
|
113
|
+
state: STATE.into(),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Turns a verified or raw claim object into a `serde_json::Value`, for comparisons.
|
|
118
|
+
pub fn to_value(json: &Json) -> Value {
|
|
119
|
+
match json {
|
|
120
|
+
Json::Null => Value::Null,
|
|
121
|
+
Json::Bool(b) => Value::Bool(*b),
|
|
122
|
+
Json::Int(i) => Value::from(i64::try_from(*i).unwrap_or(i64::MAX)),
|
|
123
|
+
Json::Number => Value::from(0.5),
|
|
124
|
+
Json::Str(s) => Value::String(s.clone()),
|
|
125
|
+
Json::Array(items) => Value::Array(items.iter().map(to_value).collect()),
|
|
126
|
+
Json::Object(object) => object_to_value(object),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Turns a parsed object into a `serde_json::Value`.
|
|
131
|
+
pub fn object_to_value(object: &JsonObject) -> Value {
|
|
132
|
+
Value::Object(
|
|
133
|
+
object
|
|
134
|
+
.keys()
|
|
135
|
+
.map(|k| (k.clone(), to_value(object.get(k).expect("member"))))
|
|
136
|
+
.collect(),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// One sign-in attempt, built from a good one by mutating the pieces a test wants to break.
|
|
141
|
+
pub struct Scenario {
|
|
142
|
+
pub header: Map<String, Value>,
|
|
143
|
+
pub claims: Map<String, Value>,
|
|
144
|
+
pub signer: TestKey,
|
|
145
|
+
pub state: Option<String>,
|
|
146
|
+
pub cookie: Option<SigninCookie>,
|
|
147
|
+
pub flip_signature: bool,
|
|
148
|
+
pub replace_token: Option<String>,
|
|
149
|
+
pub spend_first: bool,
|
|
150
|
+
pub now: i64,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
impl Scenario {
|
|
154
|
+
/// Builds a scenario that a verifier accepts.
|
|
155
|
+
pub fn good() -> Self {
|
|
156
|
+
let claims = json!({
|
|
157
|
+
"iss": ISSUER, "aud": SITE, "env": ENVIRONMENT, "sub": EMAIL,
|
|
158
|
+
"idp_iss": "https://idp.example", "idp_sub": "idp-subject-0001",
|
|
159
|
+
"email": EMAIL, "email_verified": true, "roles": ["Admin"], "nonce": NONCE,
|
|
160
|
+
"iat": NOW, "nbf": NOW, "exp": NOW + 60, "jti": "jti-scenario-0000000000"
|
|
161
|
+
});
|
|
162
|
+
let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_A.kid()});
|
|
163
|
+
Self {
|
|
164
|
+
header: header.as_object().cloned().unwrap(),
|
|
165
|
+
claims: claims.as_object().cloned().unwrap(),
|
|
166
|
+
signer: KEY_A,
|
|
167
|
+
state: Some(STATE.into()),
|
|
168
|
+
cookie: Some(good_cookie()),
|
|
169
|
+
flip_signature: false,
|
|
170
|
+
replace_token: None,
|
|
171
|
+
spend_first: false,
|
|
172
|
+
now: NOW,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Builds the token text, signed over the (possibly mutated) header and claims.
|
|
177
|
+
pub fn token(&self) -> String {
|
|
178
|
+
if let Some(text) = &self.replace_token {
|
|
179
|
+
return text.clone();
|
|
180
|
+
}
|
|
181
|
+
let header = Value::Object(self.header.clone()).to_string();
|
|
182
|
+
let claims = Value::Object(self.claims.clone()).to_string();
|
|
183
|
+
let token = assemble(&header, &claims, &self.signer);
|
|
184
|
+
if self.flip_signature {
|
|
185
|
+
flip_last_signature_bit(&token)
|
|
186
|
+
} else {
|
|
187
|
+
token
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Runs the scenario through `verifier`, spending the jti first when asked.
|
|
192
|
+
pub fn run(&self, verifier: &Verifier<StaticKeys>) -> Result<VerifiedClaims, Refusal> {
|
|
193
|
+
if self.spend_first {
|
|
194
|
+
let jti = self
|
|
195
|
+
.claims
|
|
196
|
+
.get("jti")
|
|
197
|
+
.and_then(Value::as_str)
|
|
198
|
+
.expect("a jti to spend");
|
|
199
|
+
assert!(verifier.jti_store().claim(jti, self.now + 90, self.now));
|
|
200
|
+
}
|
|
201
|
+
let token = self.token();
|
|
202
|
+
let request = SigninRequest {
|
|
203
|
+
token: Some(&token),
|
|
204
|
+
state: self.state.as_deref(),
|
|
205
|
+
cookie: self.cookie.as_ref(),
|
|
206
|
+
};
|
|
207
|
+
verifier.verify(&request, self.now)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Changes the first bit of the signature's first byte, keeping the segment canonical.
|
|
212
|
+
pub fn flip_last_signature_bit(token: &str) -> String {
|
|
213
|
+
let (input, signature) = token.rsplit_once('.').expect("three segments");
|
|
214
|
+
let mut bytes = super::jws::decode_base64url(signature).expect("canonical signature");
|
|
215
|
+
bytes[0] ^= 1;
|
|
216
|
+
format!("{input}.{}", encode_base64url(&bytes))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// A key-set fetcher backed by a closure, for cache tests.
|
|
220
|
+
pub struct FnFetcher(pub Box<dyn Fn() -> Result<String, super::jwks::FetchFailed> + Send + Sync>);
|
|
221
|
+
|
|
222
|
+
impl super::jwks::JwksFetcher for FnFetcher {
|
|
223
|
+
fn fetch(&self) -> Result<String, super::jwks::FetchFailed> {
|
|
224
|
+
(self.0)()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// A key set document listing `keys`.
|
|
229
|
+
pub fn jwks_document(keys: &[&TestKey]) -> String {
|
|
230
|
+
let entries: Vec<String> = keys
|
|
231
|
+
.iter()
|
|
232
|
+
.map(|k| format!(r#"{{"kty":"OKP","crv":"Ed25519","x":"{}"}}"#, k.x))
|
|
233
|
+
.collect();
|
|
234
|
+
format!(r#"{{"keys":[{}]}}"#, entries.join(","))
|
|
235
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
//! The key set cache honours how long the issuer says its answer may be kept, within bounds.
|
|
2
|
+
|
|
3
|
+
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
|
|
4
|
+
use std::sync::Arc;
|
|
5
|
+
|
|
6
|
+
use super::jwks::{FetchFailed, JwksCache, JwksFetcher, KeySource, MAX_TTL_SECONDS};
|
|
7
|
+
use super::test_support::{jwks_document, KEY_A};
|
|
8
|
+
|
|
9
|
+
struct Hinted {
|
|
10
|
+
fetches: Arc<AtomicUsize>,
|
|
11
|
+
hint: Arc<AtomicI64>,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
impl JwksFetcher for Hinted {
|
|
15
|
+
fn fetch(&self) -> Result<String, FetchFailed> {
|
|
16
|
+
self.fetches.fetch_add(1, Ordering::SeqCst);
|
|
17
|
+
Ok(jwks_document(&[&KEY_A]))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn ttl_hint(&self) -> Option<i64> {
|
|
21
|
+
let hint = self.hint.load(Ordering::SeqCst);
|
|
22
|
+
(hint >= 0).then_some(hint)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const START: i64 = 1_000;
|
|
27
|
+
|
|
28
|
+
fn cache(hint: i64) -> (JwksCache<Hinted>, Arc<AtomicUsize>, Arc<AtomicI64>) {
|
|
29
|
+
let fetches = Arc::new(AtomicUsize::new(0));
|
|
30
|
+
let hint = Arc::new(AtomicI64::new(hint));
|
|
31
|
+
let fetcher = Hinted {
|
|
32
|
+
fetches: fetches.clone(),
|
|
33
|
+
hint: hint.clone(),
|
|
34
|
+
};
|
|
35
|
+
(JwksCache::new(fetcher), fetches, hint)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Asks for the known key at `START + offset` and returns how many fetches have happened.
|
|
39
|
+
fn ask(cache: &JwksCache<Hinted>, fetches: &AtomicUsize, offset: i64) -> usize {
|
|
40
|
+
assert!(cache.key_for(&KEY_A.kid(), START + offset).is_some());
|
|
41
|
+
fetches.load(Ordering::SeqCst)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[test]
|
|
45
|
+
fn without_a_hint_the_default_time_to_live_applies() {
|
|
46
|
+
let (cache, fetches, _) = cache(-1);
|
|
47
|
+
assert_eq!(ask(&cache, &fetches, 0), 1);
|
|
48
|
+
assert_eq!(ask(&cache, &fetches, 299), 1);
|
|
49
|
+
assert_eq!(ask(&cache, &fetches, 300), 2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[test]
|
|
53
|
+
fn a_shorter_hint_makes_the_keys_stale_sooner() {
|
|
54
|
+
let (cache, fetches, _) = cache(120);
|
|
55
|
+
assert_eq!(ask(&cache, &fetches, 0), 1);
|
|
56
|
+
assert_eq!(ask(&cache, &fetches, 119), 1);
|
|
57
|
+
assert_eq!(ask(&cache, &fetches, 120), 2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#[test]
|
|
61
|
+
fn a_longer_hint_keeps_the_keys_longer() {
|
|
62
|
+
let (cache, fetches, _) = cache(900);
|
|
63
|
+
assert_eq!(ask(&cache, &fetches, 0), 1);
|
|
64
|
+
assert_eq!(ask(&cache, &fetches, 899), 1);
|
|
65
|
+
assert_eq!(ask(&cache, &fetches, 900), 2);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[test]
|
|
69
|
+
fn a_huge_hint_is_capped() {
|
|
70
|
+
let (cache, fetches, _) = cache(10_000_000);
|
|
71
|
+
assert_eq!(ask(&cache, &fetches, 0), 1);
|
|
72
|
+
assert_eq!(ask(&cache, &fetches, MAX_TTL_SECONDS - 1), 1);
|
|
73
|
+
assert_eq!(ask(&cache, &fetches, MAX_TTL_SECONDS), 2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[test]
|
|
77
|
+
fn a_tiny_hint_cannot_turn_the_cache_into_a_fetch_amplifier() {
|
|
78
|
+
for hint in [0, 1, 29] {
|
|
79
|
+
let (cache, fetches, _) = cache(hint);
|
|
80
|
+
assert_eq!(ask(&cache, &fetches, 0), 1, "hint {hint}");
|
|
81
|
+
assert_eq!(ask(&cache, &fetches, 29), 1, "hint {hint}");
|
|
82
|
+
assert_eq!(ask(&cache, &fetches, 30), 2, "hint {hint}");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn the_next_fetch_without_a_hint_returns_to_the_default() {
|
|
88
|
+
let (cache, fetches, hint) = cache(60);
|
|
89
|
+
assert_eq!(ask(&cache, &fetches, 0), 1);
|
|
90
|
+
hint.store(-1, Ordering::SeqCst);
|
|
91
|
+
assert_eq!(ask(&cache, &fetches, 60), 2);
|
|
92
|
+
assert_eq!(ask(&cache, &fetches, 60 + 299), 2);
|
|
93
|
+
assert_eq!(ask(&cache, &fetches, 60 + 300), 3);
|
|
94
|
+
}
|