hecks 3.11.0 → 3.12.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
  3. data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
  4. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  5. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  6. data/lib/hecks/bluebook/assembly/marks.rb +3 -15
  7. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  8. data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
  9. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  10. data/lib/hecks/bluebook.rb +1 -0
  11. data/lib/hecks/deploy/bluebook/deploy.bluebook +16 -2
  12. data/lib/hecks/gate/stages.yml +32 -0
  13. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
  14. data/lib/hecks/hecks/hecks.world +3 -0
  15. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  16. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  17. data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
  18. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  19. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  20. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  22. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  23. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  24. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  25. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  26. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  27. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  28. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  29. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  30. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  31. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
  32. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  33. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  34. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  35. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  36. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  37. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  38. data/lib/hecks/projections/deploy/box.rb +15 -3
  39. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  40. data/lib/hecks/projector/cli_audience.rb +20 -0
  41. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  42. data/lib/hecks/rust_build/host.rb +11 -4
  43. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  44. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  45. data/lib/hecks/tools/ci_gates.rb +20 -1
  46. data/lib/hecks/tools/lanes/files.rb +8 -0
  47. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  48. data/lib/hecks/tools/lanes.rb +1 -0
  49. data/lib/hecks/tools/release_only.rb +111 -0
  50. data/lib/hecks/version.rb +1 -1
  51. data/lib/hecks/vocabulary.rb +15 -1
  52. data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
  53. data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
  54. data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json} +1 -1
  55. data/rust/host/HECKS_RELEASE +1 -1
  56. data/rust/host/src/extension.rs +13 -1
  57. data/rust/host/src/lib.rs +1 -0
  58. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  59. data/rust/host/src/platform_signin/config.rs +512 -0
  60. data/rust/host/src/platform_signin/cookie.rs +237 -0
  61. data/rust/host/src/platform_signin/extension.rs +190 -0
  62. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  63. data/rust/host/src/platform_signin/handler.rs +251 -0
  64. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  65. data/rust/host/src/platform_signin/jti.rs +196 -0
  66. data/rust/host/src/platform_signin/jwks.rs +542 -0
  67. data/rust/host/src/platform_signin/jws.rs +249 -0
  68. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  69. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  70. data/rust/host/src/platform_signin/mod.rs +75 -0
  71. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  72. data/rust/host/src/platform_signin/pages.rs +204 -0
  73. data/rust/host/src/platform_signin/query.rs +233 -0
  74. data/rust/host/src/platform_signin/session.rs +151 -0
  75. data/rust/host/src/platform_signin/settings.rs +385 -0
  76. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  77. data/rust/host/src/platform_signin/test_support.rs +235 -0
  78. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  79. data/rust/host/src/platform_signin/verify.rs +604 -0
  80. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  81. data/rust/host/src/web.rs +50 -6
  82. data/rust/parser/src/parse/value_object.rs +22 -10
  83. metadata +42 -4
  84. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  85. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
@@ -0,0 +1,1100 @@
1
+ //! Attacks on the verifier, each named for what the attacker tries.
2
+ //!
3
+ //! The first test is the positive control: a token this suite signed by hand must be accepted, so
4
+ //! every refusal below can only come from a verifier that tells the attack from a good token. The
5
+ //! examples mirror the issuer's own attack suite for the reference verifier.
6
+
7
+ use std::sync::atomic::{AtomicUsize, Ordering};
8
+ use std::sync::Arc;
9
+
10
+ use hmac::{Hmac, Mac};
11
+ use serde_json::{json, Value};
12
+ use sha2::Sha256;
13
+
14
+ use super::config::PlatformSigninConfig;
15
+ use super::jti::JtiStore;
16
+ use super::jwks::{JwksCache, KeySource};
17
+ use super::jws::{decode_base64url, encode_base64url};
18
+ use super::test_support::*;
19
+ use super::verify::{Reason, SigninRequest, Verifier};
20
+
21
+ fn refused_with(mutate: impl FnOnce(&mut Scenario)) -> Reason {
22
+ let mut scenario = Scenario::good();
23
+ mutate(&mut scenario);
24
+ scenario
25
+ .run(&verifier_for(&[&KEY_A]))
26
+ .expect_err("must be refused")
27
+ .reason
28
+ }
29
+
30
+ fn accepted_with(mutate: impl FnOnce(&mut Scenario)) {
31
+ let mut scenario = Scenario::good();
32
+ mutate(&mut scenario);
33
+ scenario
34
+ .run(&verifier_for(&[&KEY_A]))
35
+ .expect("must be accepted");
36
+ }
37
+
38
+ fn set(scenario: &mut Scenario, claim: &str, value: Value) {
39
+ scenario.claims.insert(claim.into(), value);
40
+ }
41
+
42
+ fn raw_token_reason(token: Option<&str>) -> Reason {
43
+ let verifier = verifier_for(&[&KEY_A]);
44
+ let cookie = good_cookie();
45
+ let request = SigninRequest {
46
+ token,
47
+ state: Some(STATE),
48
+ cookie: Some(&cookie),
49
+ };
50
+ verifier
51
+ .verify(&request, NOW)
52
+ .expect_err("must be refused")
53
+ .reason
54
+ }
55
+
56
+ // --- positive controls --------------------------------------------------------------------------
57
+
58
+ #[test]
59
+ fn accepts_a_valid_token() {
60
+ assert!(Scenario::good().run(&verifier_for(&[&KEY_A])).is_ok());
61
+ }
62
+
63
+ #[test]
64
+ fn accepts_a_token_exactly_at_the_edges_of_the_window() {
65
+ accepted_with(|s| set(s, "nbf", json!(NOW + 30)));
66
+ accepted_with(|s| {
67
+ set(s, "iat", json!(NOW - 89));
68
+ set(s, "nbf", json!(NOW - 89));
69
+ set(s, "exp", json!(NOW - 29));
70
+ });
71
+ }
72
+
73
+ // --- algorithm attacks -----------------------------------------------------------------------
74
+
75
+ #[test]
76
+ fn rejects_alg_none_with_and_without_a_signature_segment() {
77
+ let header = json!({"alg": "none", "typ": "JWT", "kid": KEY_A.kid()}).to_string();
78
+ let claims = Scenario::good().claims;
79
+ let body = encode_base64url(Value::Object(claims).to_string().as_bytes());
80
+ let head = encode_base64url(header.as_bytes());
81
+ for token in [
82
+ format!("{head}.{body}."),
83
+ format!("{head}.{body}.{}", encode_base64url(&[7u8; 64])),
84
+ ] {
85
+ assert_eq!(raw_token_reason(Some(&token)), Reason::BadAlg, "{token}");
86
+ }
87
+ for alg in ["None", "NONE", "nOnE", ""] {
88
+ let head = encode_base64url(
89
+ json!({"alg": alg, "typ": "JWT", "kid": KEY_A.kid()})
90
+ .to_string()
91
+ .as_bytes(),
92
+ );
93
+ assert_eq!(
94
+ raw_token_reason(Some(&format!("{head}.{body}."))),
95
+ Reason::BadAlg,
96
+ "{alg:?}"
97
+ );
98
+ }
99
+ }
100
+
101
+ #[test]
102
+ fn rejects_hs256_signed_with_the_public_key_as_the_hmac_secret() {
103
+ let public_raw = decode_base64url(X_A).unwrap();
104
+ let secrets: Vec<Vec<u8>> = vec![
105
+ public_raw,
106
+ X_A.as_bytes().to_vec(),
107
+ format!(r#"{{"kty":"OKP","crv":"Ed25519","x":"{X_A}"}}"#).into_bytes(),
108
+ ];
109
+ for secret in secrets {
110
+ let head = encode_base64url(
111
+ json!({"alg": "HS256", "typ": "JWT", "kid": KEY_A.kid()})
112
+ .to_string()
113
+ .as_bytes(),
114
+ );
115
+ let body = encode_base64url(
116
+ Value::Object(Scenario::good().claims)
117
+ .to_string()
118
+ .as_bytes(),
119
+ );
120
+ let input = format!("{head}.{body}");
121
+ let mut mac = Hmac::<Sha256>::new_from_slice(&secret).unwrap();
122
+ mac.update(input.as_bytes());
123
+ let token = format!("{input}.{}", encode_base64url(&mac.finalize().into_bytes()));
124
+ assert_eq!(raw_token_reason(Some(&token)), Reason::BadAlg);
125
+ }
126
+ }
127
+
128
+ #[test]
129
+ fn rejects_every_alg_other_than_eddsa_even_with_a_valid_eddsa_signature_over_that_header() {
130
+ for alg in [
131
+ "HS256", "HS384", "HS512", "RS256", "RS512", "ES256", "ES384", "PS256", "none", "eddsa",
132
+ "EDDSA", "Ed25519", "EdDSA ", "ES256K",
133
+ ] {
134
+ assert_eq!(
135
+ refused_with(|s| {
136
+ s.header.insert("alg".into(), json!(alg));
137
+ }),
138
+ Reason::BadAlg,
139
+ "{alg:?}"
140
+ );
141
+ }
142
+ assert_eq!(
143
+ refused_with(|s| {
144
+ s.header.remove("alg");
145
+ }),
146
+ Reason::BadAlg
147
+ );
148
+ assert_eq!(
149
+ refused_with(|s| {
150
+ s.header.insert("alg".into(), json!(["EdDSA"]));
151
+ }),
152
+ Reason::BadAlg
153
+ );
154
+ assert_eq!(
155
+ refused_with(|s| {
156
+ s.header.insert("alg".into(), Value::Null);
157
+ }),
158
+ Reason::BadAlg
159
+ );
160
+ }
161
+
162
+ #[test]
163
+ fn rejects_a_typ_that_is_not_jwt() {
164
+ for typ in [json!("jwt"), json!("JWS"), json!(""), json!(1), Value::Null] {
165
+ assert_eq!(
166
+ refused_with(|s| {
167
+ s.header.insert("typ".into(), typ.clone());
168
+ }),
169
+ Reason::Malformed,
170
+ "{typ}"
171
+ );
172
+ }
173
+ assert_eq!(
174
+ refused_with(|s| {
175
+ s.header.remove("typ");
176
+ }),
177
+ Reason::Malformed
178
+ );
179
+ }
180
+
181
+ #[test]
182
+ fn ignores_an_embedded_jwk_and_jku_and_never_uses_them() {
183
+ let attacker_jwk = json!({"kty": "OKP", "crv": "Ed25519", "x": X_B, "kid": KEY_B.kid()});
184
+ let reason = refused_with(|s| {
185
+ s.signer = KEY_B;
186
+ s.header.insert("jwk".into(), attacker_jwk.clone());
187
+ s.header
188
+ .insert("jku".into(), json!("https://evil.example/jwks.json"));
189
+ });
190
+ assert_eq!(
191
+ reason,
192
+ Reason::BadSignature,
193
+ "signed by the attacker, kid names the real key"
194
+ );
195
+ let reason = refused_with(|s| {
196
+ s.signer = KEY_B;
197
+ s.header.insert("kid".into(), json!(KEY_B.kid()));
198
+ s.header.insert("jwk".into(), attacker_jwk.clone());
199
+ });
200
+ assert_eq!(
201
+ reason,
202
+ Reason::UnknownKid,
203
+ "the attacker's own key is not in the key set"
204
+ );
205
+ }
206
+
207
+ #[test]
208
+ fn rejects_an_unknown_kid_a_missing_kid_and_a_kid_of_the_wrong_type() {
209
+ assert_eq!(
210
+ refused_with(|s| {
211
+ s.header.insert("kid".into(), json!("nope"));
212
+ }),
213
+ Reason::UnknownKid
214
+ );
215
+ assert_eq!(
216
+ refused_with(|s| {
217
+ s.header.remove("kid");
218
+ }),
219
+ Reason::UnknownKid
220
+ );
221
+ for kid in [json!(7), json!(["x"]), Value::Null, json!({"a": 1})] {
222
+ assert_eq!(
223
+ refused_with(|s| {
224
+ s.header.insert("kid".into(), kid.clone());
225
+ }),
226
+ Reason::UnknownKid
227
+ );
228
+ }
229
+ assert_eq!(
230
+ refused_with(|s| {
231
+ s.header.insert("kid".into(), json!(KID_A.to_uppercase()));
232
+ }),
233
+ Reason::UnknownKid
234
+ );
235
+ }
236
+
237
+ #[test]
238
+ fn rejects_a_token_signed_by_another_key_that_claims_the_real_kid() {
239
+ assert_eq!(refused_with(|s| s.signer = KEY_B), Reason::BadSignature);
240
+ }
241
+
242
+ // --- tampering -------------------------------------------------------------------------------
243
+
244
+ fn parts(scenario: &Scenario) -> (String, String, String) {
245
+ let token = scenario.token();
246
+ let mut it = token.split('.');
247
+ (
248
+ it.next().unwrap().into(),
249
+ it.next().unwrap().into(),
250
+ it.next().unwrap().into(),
251
+ )
252
+ }
253
+
254
+ #[test]
255
+ fn rejects_a_tampered_payload() {
256
+ let (head, _, signature) = parts(&Scenario::good());
257
+ let mut evil = Scenario::good().claims;
258
+ evil.insert("email".into(), json!("admin@example.test"));
259
+ evil.insert("sub".into(), json!("admin@example.test"));
260
+ let tampered = format!(
261
+ "{head}.{}.{signature}",
262
+ encode_base64url(Value::Object(evil).to_string().as_bytes())
263
+ );
264
+ assert_eq!(raw_token_reason(Some(&tampered)), Reason::BadSignature);
265
+ }
266
+
267
+ #[test]
268
+ fn rejects_a_tampered_header_with_the_kid_swapped_to_another_known_kid() {
269
+ let verifier = verifier_for(&[&KEY_A, &KEY_B]);
270
+ let (_, body, signature) = parts(&Scenario::good());
271
+ let head = encode_base64url(
272
+ json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_B.kid()})
273
+ .to_string()
274
+ .as_bytes(),
275
+ );
276
+ let cookie = good_cookie();
277
+ let token = format!("{head}.{body}.{signature}");
278
+ let request = SigninRequest {
279
+ token: Some(&token),
280
+ state: Some(STATE),
281
+ cookie: Some(&cookie),
282
+ };
283
+ assert_eq!(
284
+ verifier.verify(&request, NOW).unwrap_err().reason,
285
+ Reason::BadSignature
286
+ );
287
+ }
288
+
289
+ #[test]
290
+ fn rejects_a_tampered_signature_one_bit_flipped_and_every_wrong_length() {
291
+ assert_eq!(
292
+ refused_with(|s| s.flip_signature = true),
293
+ Reason::BadSignature
294
+ );
295
+ let (head, body, signature) = parts(&Scenario::good());
296
+ let bytes = decode_base64url(&signature).unwrap();
297
+ for wrong in [&bytes[..63], &bytes[..32], &bytes[..1], &[][..]] {
298
+ let token = format!("{head}.{body}.{}", encode_base64url(wrong));
299
+ assert_eq!(
300
+ raw_token_reason(Some(&token)),
301
+ Reason::BadSignature,
302
+ "{} bytes",
303
+ wrong.len()
304
+ );
305
+ }
306
+ let mut longer = bytes.clone();
307
+ longer.extend_from_slice(&[0, 0]);
308
+ assert_eq!(
309
+ raw_token_reason(Some(&format!(
310
+ "{head}.{body}.{}",
311
+ encode_base64url(&longer)
312
+ ))),
313
+ Reason::BadSignature
314
+ );
315
+ }
316
+
317
+ // --- structure -------------------------------------------------------------------------------
318
+
319
+ #[test]
320
+ fn rejects_truncated_extra_segment_empty_and_missing_tokens() {
321
+ let token = Scenario::good().token();
322
+ let (head, body, signature) = parts(&Scenario::good());
323
+ assert_eq!(
324
+ raw_token_reason(Some(&token[..token.len() - 10])),
325
+ Reason::BadSignature,
326
+ "a shorter signature still parses"
327
+ );
328
+ assert_eq!(
329
+ raw_token_reason(Some(&format!("{head}.{body}"))),
330
+ Reason::Malformed
331
+ );
332
+ assert_eq!(
333
+ raw_token_reason(Some(&format!("{token}.{signature}"))),
334
+ Reason::Malformed
335
+ );
336
+ assert_eq!(
337
+ raw_token_reason(Some(&format!("{token}."))),
338
+ Reason::Malformed
339
+ );
340
+ assert_eq!(raw_token_reason(Some("")), Reason::Malformed);
341
+ assert_eq!(raw_token_reason(Some("..")), Reason::Malformed);
342
+ assert_eq!(raw_token_reason(None), Reason::Malformed);
343
+ }
344
+
345
+ #[test]
346
+ fn rejects_non_base64url_segments_and_non_object_json() {
347
+ let (head, body, signature) = parts(&Scenario::good());
348
+ let cases = [
349
+ format!("{head}=.{body}.{signature}"),
350
+ format!("{head}.{}+.{signature}", &body[..body.len() - 1]),
351
+ format!("{head}./{body}.{signature}"),
352
+ format!("{head}.{} {}.{signature}", &body[..4], &body[4..]),
353
+ format!("{head}.{body}\n.{signature}"),
354
+ format!("{}.{body}.{signature}", encode_base64url(b"not json")),
355
+ format!("{head}.{}.{signature}", encode_base64url(b"[1,2]")),
356
+ format!("{head}.{}.{signature}", encode_base64url(b"\"s\"")),
357
+ format!("{head}.{}.{signature}", encode_base64url(b"")),
358
+ format!("{head}.{body}.{}", signature.replace(&signature[..1], "!")),
359
+ ];
360
+ for token in cases {
361
+ let reason = raw_token_reason(Some(&token));
362
+ assert_eq!(reason, Reason::Malformed, "{token}");
363
+ }
364
+ }
365
+
366
+ #[test]
367
+ fn rejects_a_header_or_payload_that_repeats_a_key() {
368
+ let kid = KEY_A.kid();
369
+ let claims = Value::Object(Scenario::good().claims).to_string();
370
+ let header_dup = format!(r#"{{"alg":"none","alg":"EdDSA","typ":"JWT","kid":"{kid}"}}"#);
371
+ let payload_dup = format!(r#"{{"aud":"other",{}"#, &claims[1..]);
372
+ assert_eq!(
373
+ raw_token_reason(Some(&assemble(&header_dup, &claims, &KEY_A))),
374
+ Reason::Malformed
375
+ );
376
+ let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}).to_string();
377
+ assert_eq!(
378
+ raw_token_reason(Some(&assemble(&header, &payload_dup, &KEY_A))),
379
+ Reason::Malformed
380
+ );
381
+ }
382
+
383
+ #[test]
384
+ fn rejects_a_non_canonical_signature_spelling() {
385
+ let (head, body, signature) = parts(&Scenario::good());
386
+ let last = signature.chars().last().unwrap();
387
+ let spare = if last == 'A' { 'B' } else { 'A' };
388
+ let respelled = format!("{head}.{body}.{}{spare}", &signature[..signature.len() - 1]);
389
+ assert_eq!(raw_token_reason(Some(&respelled)), Reason::Malformed);
390
+ assert_eq!(
391
+ raw_token_reason(Some(&format!("{head}.{body}.{signature}="))),
392
+ Reason::Malformed
393
+ );
394
+ }
395
+
396
+ fn token_of_exactly(len: usize) -> String {
397
+ for pad in 0..len {
398
+ for jti in 0..4 {
399
+ let mut scenario = Scenario::good();
400
+ set(&mut scenario, "pad", json!("x".repeat(pad)));
401
+ set(
402
+ &mut scenario,
403
+ "jti",
404
+ json!(format!("jti-size-{}", "j".repeat(jti))),
405
+ );
406
+ let token = scenario.token();
407
+ if token.len() == len {
408
+ return token;
409
+ }
410
+ }
411
+ }
412
+ panic!("no token of {len} bytes");
413
+ }
414
+
415
+ #[test]
416
+ fn rejects_an_oversized_token_before_parsing_it_and_accepts_one_at_the_cap() {
417
+ assert_eq!(
418
+ raw_token_reason(Some(&token_of_exactly(4097))),
419
+ Reason::TooLarge
420
+ );
421
+ assert_eq!(
422
+ raw_token_reason(Some(&"x".repeat(100_000))),
423
+ Reason::TooLarge
424
+ );
425
+ let at_cap = token_of_exactly(4096);
426
+ let cookie = good_cookie();
427
+ let request = SigninRequest {
428
+ token: Some(&at_cap),
429
+ state: Some(STATE),
430
+ cookie: Some(&cookie),
431
+ };
432
+ assert!(verifier_for(&[&KEY_A]).verify(&request, NOW).is_ok());
433
+ }
434
+
435
+ // --- claims ----------------------------------------------------------------------------------
436
+
437
+ #[test]
438
+ fn rejects_claims_of_the_wrong_json_type() {
439
+ assert_eq!(
440
+ refused_with(|s| set(s, "exp", json!("1800000060"))),
441
+ Reason::Malformed
442
+ );
443
+ assert_eq!(
444
+ refused_with(|s| set(s, "exp", json!(1800000060.0))),
445
+ Reason::Malformed
446
+ );
447
+ assert_eq!(
448
+ refused_with(|s| set(s, "iat", json!(1800000000.5))),
449
+ Reason::Malformed
450
+ );
451
+ assert_eq!(
452
+ refused_with(|s| set(s, "nbf", json!(true))),
453
+ Reason::Malformed
454
+ );
455
+ assert_eq!(
456
+ refused_with(|s| set(s, "aud", json!([SITE]))),
457
+ Reason::BadAud
458
+ );
459
+ assert_eq!(
460
+ refused_with(|s| set(s, "env", json!([ENVIRONMENT]))),
461
+ Reason::BadEnv
462
+ );
463
+ assert_eq!(
464
+ refused_with(|s| set(s, "iss", json!([ISSUER]))),
465
+ Reason::BadIss
466
+ );
467
+ }
468
+
469
+ #[test]
470
+ fn rejects_the_wrong_iss_including_lookalikes() {
471
+ for iss in [
472
+ "https://issuer.example/",
473
+ "http://issuer.example",
474
+ "https://ISSUER.example",
475
+ "https://issuer.example.evil.example",
476
+ "https://evil.example",
477
+ "issuer.example",
478
+ "",
479
+ ] {
480
+ assert_eq!(
481
+ refused_with(|s| set(s, "iss", json!(iss))),
482
+ Reason::BadIss,
483
+ "{iss:?}"
484
+ );
485
+ }
486
+ }
487
+
488
+ #[test]
489
+ fn rejects_the_wrong_aud_and_env() {
490
+ for aud in ["site-b", "SITE-A", "site-a ", "site", "platform", ""] {
491
+ assert_eq!(
492
+ refused_with(|s| set(s, "aud", json!(aud))),
493
+ Reason::BadAud,
494
+ "{aud:?}"
495
+ );
496
+ }
497
+ for env in ["staging", "Production", "prod", "production ", ""] {
498
+ assert_eq!(
499
+ refused_with(|s| set(s, "env", json!(env))),
500
+ Reason::BadEnv,
501
+ "{env:?}"
502
+ );
503
+ }
504
+ }
505
+
506
+ #[test]
507
+ fn rejects_an_expired_token_at_exp_minus_skew_and_not_a_second_sooner() {
508
+ assert_eq!(
509
+ refused_with(|s| {
510
+ set(s, "iat", json!(NOW - 90));
511
+ set(s, "nbf", json!(NOW - 90));
512
+ set(s, "exp", json!(NOW - 30));
513
+ }),
514
+ Reason::Expired
515
+ );
516
+ assert_eq!(
517
+ refused_with(|s| {
518
+ set(s, "iat", json!(NOW - 9000));
519
+ set(s, "nbf", json!(NOW - 9000));
520
+ set(s, "exp", json!(NOW - 8940));
521
+ }),
522
+ Reason::Expired
523
+ );
524
+ }
525
+
526
+ #[test]
527
+ fn rejects_a_not_yet_valid_token_beyond_the_skew() {
528
+ assert_eq!(
529
+ refused_with(|s| set(s, "nbf", json!(NOW + 31))),
530
+ Reason::NotYetValid
531
+ );
532
+ assert_eq!(
533
+ refused_with(|s| {
534
+ set(s, "nbf", json!(NOW + 3600));
535
+ set(s, "iat", json!(NOW + 3600));
536
+ set(s, "exp", json!(NOW + 3660));
537
+ }),
538
+ Reason::NotYetValid
539
+ );
540
+ }
541
+
542
+ #[test]
543
+ fn rejects_a_lifetime_over_sixty_seconds_even_when_currently_valid() {
544
+ assert_eq!(
545
+ refused_with(|s| set(s, "exp", json!(NOW + 61))),
546
+ Reason::Lifetime
547
+ );
548
+ assert_eq!(
549
+ refused_with(|s| set(s, "iat", json!(NOW - 1))),
550
+ Reason::Lifetime
551
+ );
552
+ assert_eq!(
553
+ refused_with(|s| set(s, "exp", json!(NOW))),
554
+ Reason::Lifetime,
555
+ "zero lifetime"
556
+ );
557
+ assert_eq!(
558
+ refused_with(|s| set(s, "exp", json!(NOW - 1))),
559
+ Reason::Lifetime,
560
+ "negative lifetime"
561
+ );
562
+ accepted_with(|s| set(s, "exp", json!(NOW + 1)));
563
+ }
564
+
565
+ #[test]
566
+ fn rejects_a_token_missing_any_required_claim_or_holding_null() {
567
+ for claim in [
568
+ "iss",
569
+ "aud",
570
+ "env",
571
+ "sub",
572
+ "email",
573
+ "email_verified",
574
+ "nonce",
575
+ "iat",
576
+ "nbf",
577
+ "exp",
578
+ "jti",
579
+ ] {
580
+ assert_eq!(
581
+ refused_with(|s| {
582
+ s.claims.remove(claim);
583
+ }),
584
+ Reason::MissingClaim,
585
+ "{claim}"
586
+ );
587
+ assert_eq!(
588
+ refused_with(|s| set(s, claim, Value::Null)),
589
+ Reason::MissingClaim,
590
+ "null {claim}"
591
+ );
592
+ }
593
+ accepted_with(|s| {
594
+ s.claims.remove("roles");
595
+ s.claims.remove("idp_iss");
596
+ s.claims.remove("idp_sub");
597
+ });
598
+ }
599
+
600
+ // --- single use, nonce, state ----------------------------------------------------------------
601
+
602
+ #[test]
603
+ fn accepts_a_jti_once_and_rejects_the_replay() {
604
+ let verifier = verifier_for(&[&KEY_A]);
605
+ let scenario = Scenario::good();
606
+ assert!(scenario.run(&verifier).is_ok());
607
+ assert_eq!(scenario.run(&verifier).unwrap_err().reason, Reason::Replay);
608
+ assert_eq!(scenario.run(&verifier).unwrap_err().reason, Reason::Replay);
609
+ }
610
+
611
+ #[test]
612
+ fn does_not_burn_the_jti_when_an_earlier_check_fails() {
613
+ let verifier = verifier_for(&[&KEY_A]);
614
+ let mut attacker = Scenario::good();
615
+ attacker.cookie = Some(super::verify::SigninCookie {
616
+ nonce: "attackers-own-nonce-value".into(),
617
+ state: STATE.into(),
618
+ });
619
+ assert_eq!(
620
+ attacker.run(&verifier).unwrap_err().reason,
621
+ Reason::NonceMismatch
622
+ );
623
+ assert!(
624
+ Scenario::good().run(&verifier).is_ok(),
625
+ "the genuine browser still redeems it"
626
+ );
627
+ }
628
+
629
+ #[test]
630
+ fn records_the_jti_until_exp_plus_skew_and_not_before() {
631
+ let verifier = verifier_for(&[&KEY_A]);
632
+ Scenario::good().run(&verifier).unwrap();
633
+ let store = verifier.jti_store();
634
+ assert!(!store.claim("jti-scenario-0000000000", NOW + 200, NOW + 89));
635
+ assert!(store.claim("jti-scenario-0000000000", NOW + 200, NOW + 90));
636
+ }
637
+
638
+ #[test]
639
+ fn a_jti_that_is_not_a_string_is_a_replay_not_a_pass() {
640
+ assert_eq!(refused_with(|s| set(s, "jti", json!(5))), Reason::Replay);
641
+ assert_eq!(refused_with(|s| set(s, "jti", json!(""))), Reason::Replay);
642
+ }
643
+
644
+ #[test]
645
+ fn rejects_a_nonce_that_differs_from_the_cookie() {
646
+ let mut scenario = Scenario::good();
647
+ scenario.cookie.as_mut().unwrap().nonce = "x".repeat(20);
648
+ assert_eq!(
649
+ scenario.run(&verifier_for(&[&KEY_A])).unwrap_err().reason,
650
+ Reason::NonceMismatch
651
+ );
652
+ assert_eq!(
653
+ refused_with(|s| set(s, "nonce", json!(NONCE.to_lowercase()))),
654
+ Reason::NonceMismatch
655
+ );
656
+ assert_eq!(
657
+ refused_with(|s| set(s, "nonce", json!(7))),
658
+ Reason::NonceMismatch
659
+ );
660
+ }
661
+
662
+ #[test]
663
+ fn rejects_when_the_cookie_is_absent_or_lacks_a_value() {
664
+ assert_eq!(refused_with(|s| s.cookie = None), Reason::MissingCookie);
665
+ assert_eq!(
666
+ refused_with(|s| s.cookie.as_mut().unwrap().state = String::new()),
667
+ Reason::MissingCookie
668
+ );
669
+ assert_eq!(
670
+ refused_with(|s| s.cookie.as_mut().unwrap().nonce = String::new()),
671
+ Reason::MissingCookie
672
+ );
673
+ }
674
+
675
+ #[test]
676
+ fn rejects_a_state_that_differs_or_is_missing_or_empty() {
677
+ assert_eq!(
678
+ refused_with(|s| s.state = Some("other".into())),
679
+ Reason::StateMismatch
680
+ );
681
+ assert_eq!(refused_with(|s| s.state = None), Reason::StateMismatch);
682
+ assert_eq!(
683
+ refused_with(|s| s.state = Some(String::new())),
684
+ Reason::StateMismatch
685
+ );
686
+ assert_eq!(
687
+ refused_with(|s| s.state = Some(format!("{STATE} "))),
688
+ Reason::StateMismatch
689
+ );
690
+ }
691
+
692
+ #[test]
693
+ fn checks_state_before_it_looks_at_the_token_at_all() {
694
+ let verifier = verifier_for(&[&KEY_A]);
695
+ let cookie = good_cookie();
696
+ for token in [Some("garbage"), None, Some("")] {
697
+ let request = SigninRequest {
698
+ token,
699
+ state: Some("wrong"),
700
+ cookie: Some(&cookie),
701
+ };
702
+ assert_eq!(
703
+ verifier.verify(&request, NOW).unwrap_err().reason,
704
+ Reason::StateMismatch
705
+ );
706
+ }
707
+ }
708
+
709
+ // --- identity --------------------------------------------------------------------------------
710
+
711
+ #[test]
712
+ fn rejects_email_verified_false_and_a_non_boolean() {
713
+ for value in [
714
+ json!(false),
715
+ json!("true"),
716
+ json!(1),
717
+ json!([true]),
718
+ json!(""),
719
+ ] {
720
+ assert_eq!(
721
+ refused_with(|s| set(s, "email_verified", value.clone())),
722
+ Reason::EmailUnverified,
723
+ "{value}"
724
+ );
725
+ }
726
+ }
727
+
728
+ #[test]
729
+ fn handles_a_mixed_case_email_consistently() {
730
+ accepted_with(|s| set(s, "email", json!("Person@Example.TEST")));
731
+ assert_eq!(
732
+ refused_with(|s| {
733
+ set(s, "email", json!("Person@Example.TEST"));
734
+ set(s, "sub", json!("Person@Example.TEST"));
735
+ }),
736
+ Reason::SubjectMismatch
737
+ );
738
+ assert_eq!(
739
+ refused_with(|s| set(s, "sub", json!("other@example.test"))),
740
+ Reason::SubjectMismatch
741
+ );
742
+ }
743
+
744
+ #[test]
745
+ fn rejects_an_email_or_sub_that_is_not_a_string() {
746
+ assert_eq!(
747
+ refused_with(|s| set(s, "email", json!(["person@example.test"]))),
748
+ Reason::Malformed
749
+ );
750
+ assert_eq!(refused_with(|s| set(s, "sub", json!(7))), Reason::Malformed);
751
+ }
752
+
753
+ // --- another site, another environment ----------------------------------------------------------
754
+
755
+ fn verifier_pinned(site: &str, environment: &str) -> Verifier<StaticKeys> {
756
+ let config = PlatformSigninConfig::new(ISSUER, "https://issuer.example/k", site, environment);
757
+ Verifier::new(config, static_keys(&[&KEY_A]), JtiStore::new())
758
+ }
759
+
760
+ #[test]
761
+ fn a_token_for_site_a_presented_to_site_b_is_refused() {
762
+ assert_eq!(
763
+ Scenario::good()
764
+ .run(&verifier_pinned("site-b", ENVIRONMENT))
765
+ .unwrap_err()
766
+ .reason,
767
+ Reason::BadAud
768
+ );
769
+ }
770
+
771
+ #[test]
772
+ fn a_production_token_on_a_staging_site_is_refused() {
773
+ assert_eq!(
774
+ Scenario::good()
775
+ .run(&verifier_pinned(SITE, "staging"))
776
+ .unwrap_err()
777
+ .reason,
778
+ Reason::BadEnv
779
+ );
780
+ }
781
+
782
+ #[test]
783
+ fn a_verifier_pinned_to_another_issuer_refuses_this_issuers_tokens() {
784
+ let config = PlatformSigninConfig::new(
785
+ "https://other-issuer.example",
786
+ "https://other-issuer.example/k",
787
+ SITE,
788
+ ENVIRONMENT,
789
+ );
790
+ let verifier = Verifier::new(config, static_keys(&[&KEY_A]), JtiStore::new());
791
+ assert_eq!(
792
+ Scenario::good().run(&verifier).unwrap_err().reason,
793
+ Reason::BadIss
794
+ );
795
+ }
796
+
797
+ // --- keys through the cache ------------------------------------------------------------------
798
+
799
+ fn cached_verifier(documents: Vec<String>) -> (Verifier<JwksCache<FnFetcher>>, Arc<AtomicUsize>) {
800
+ let calls = Arc::new(AtomicUsize::new(0));
801
+ let seen = calls.clone();
802
+ let fetcher = FnFetcher(Box::new(move || {
803
+ let n = seen.fetch_add(1, Ordering::SeqCst);
804
+ Ok(documents[n.min(documents.len() - 1)].clone())
805
+ }));
806
+ (
807
+ Verifier::new(config(), JwksCache::new(fetcher), JtiStore::new()),
808
+ calls,
809
+ )
810
+ }
811
+
812
+ fn run_with<K: KeySource>(verifier: &Verifier<K>, scenario: &Scenario) -> Result<(), Reason> {
813
+ let token = scenario.token();
814
+ let request = SigninRequest {
815
+ token: Some(&token),
816
+ state: scenario.state.as_deref(),
817
+ cookie: scenario.cookie.as_ref(),
818
+ };
819
+ verifier
820
+ .verify(&request, scenario.now)
821
+ .map(|_| ())
822
+ .map_err(|r| r.reason)
823
+ }
824
+
825
+ #[test]
826
+ fn a_flood_of_unknown_kids_causes_one_fetch_not_one_per_token() {
827
+ let (verifier, calls) = cached_verifier(vec![jwks_document(&[&KEY_A])]);
828
+ for i in 0..100 {
829
+ let mut scenario = Scenario::good();
830
+ scenario
831
+ .header
832
+ .insert("kid".into(), json!(format!("random-kid-{i}")));
833
+ scenario.now = NOW + (i % 25);
834
+ assert_eq!(run_with(&verifier, &scenario), Err(Reason::UnknownKid));
835
+ }
836
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
837
+ }
838
+
839
+ #[test]
840
+ fn a_known_kid_does_not_refetch() {
841
+ let (verifier, calls) = cached_verifier(vec![jwks_document(&[&KEY_A])]);
842
+ for i in 0..5 {
843
+ let mut scenario = Scenario::good();
844
+ set(&mut scenario, "jti", json!(format!("jti-known-{i}")));
845
+ assert_eq!(run_with(&verifier, &scenario), Ok(()));
846
+ }
847
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
848
+ }
849
+
850
+ #[test]
851
+ fn a_previous_key_verifies_while_listed_and_is_refused_once_removed() {
852
+ let (verifier, _) = cached_verifier(vec![
853
+ jwks_document(&[&KEY_A, &KEY_B]),
854
+ jwks_document(&[&KEY_B]),
855
+ ]);
856
+ let mut old = Scenario::good();
857
+ old.signer = KEY_A;
858
+ assert_eq!(run_with(&verifier, &old), Ok(()));
859
+ let mut newer = Scenario::good();
860
+ newer.signer = KEY_B;
861
+ newer.header.insert("kid".into(), json!(KEY_B.kid()));
862
+ set(&mut newer, "jti", json!("jti-rotated-key-0001"));
863
+ assert_eq!(run_with(&verifier, &newer), Ok(()));
864
+ let mut later = Scenario::good();
865
+ later.now = NOW + 300;
866
+ set(&mut later, "iat", json!(NOW + 300));
867
+ set(&mut later, "nbf", json!(NOW + 300));
868
+ set(&mut later, "exp", json!(NOW + 360));
869
+ set(&mut later, "jti", json!("jti-after-removal-001"));
870
+ assert_eq!(run_with(&verifier, &later), Err(Reason::UnknownKid));
871
+ }
872
+
873
+ // --- the order of the checks -----------------------------------------------------------------
874
+
875
+ type Fault = fn(&mut Scenario);
876
+
877
+ fn missing_cookie(s: &mut Scenario) {
878
+ s.cookie = None;
879
+ }
880
+ fn state_mismatch(s: &mut Scenario) {
881
+ s.state = Some("wrong-state".into());
882
+ }
883
+ fn garbage_token(s: &mut Scenario) {
884
+ s.replace_token = Some("not.a.token".into());
885
+ }
886
+ fn too_large(s: &mut Scenario) {
887
+ set(s, "pad", json!("x".repeat(5000)));
888
+ }
889
+ fn bad_alg(s: &mut Scenario) {
890
+ s.header.insert("alg".into(), json!("none"));
891
+ }
892
+ fn bad_typ(s: &mut Scenario) {
893
+ s.header.insert("typ".into(), json!("JWS"));
894
+ }
895
+ fn unknown_kid(s: &mut Scenario) {
896
+ s.header.insert("kid".into(), json!("nope"));
897
+ }
898
+ fn bad_signature(s: &mut Scenario) {
899
+ s.flip_signature = true;
900
+ }
901
+ fn missing_claim(s: &mut Scenario) {
902
+ s.claims.remove("jti");
903
+ }
904
+ fn bad_iss(s: &mut Scenario) {
905
+ set(s, "iss", json!("https://evil.example"));
906
+ }
907
+ fn bad_aud(s: &mut Scenario) {
908
+ set(s, "aud", json!("site-b"));
909
+ }
910
+ fn bad_env(s: &mut Scenario) {
911
+ set(s, "env", json!("staging"));
912
+ }
913
+ fn float_exp(s: &mut Scenario) {
914
+ set(s, "exp", json!(1800000060.5));
915
+ }
916
+ fn not_yet_valid(s: &mut Scenario) {
917
+ set(s, "nbf", json!(NOW + 100));
918
+ }
919
+ fn expired(s: &mut Scenario) {
920
+ set(s, "iat", json!(NOW - 160));
921
+ set(s, "exp", json!(NOW - 100));
922
+ }
923
+ fn long_lifetime(s: &mut Scenario) {
924
+ set(s, "iat", json!(NOW - 100));
925
+ }
926
+ fn nonce_mismatch(s: &mut Scenario) {
927
+ set(s, "nonce", json!("someone-elses-nonce"));
928
+ }
929
+ fn replayed(s: &mut Scenario) {
930
+ s.spend_first = true;
931
+ }
932
+ fn unverified(s: &mut Scenario) {
933
+ set(s, "email_verified", json!(false));
934
+ }
935
+ fn wrong_subject(s: &mut Scenario) {
936
+ set(s, "sub", json!("other@example.test"));
937
+ }
938
+
939
+ fn with_both(first: Fault, second: Fault) -> (Reason, Reason) {
940
+ let run = |a: Fault, b: Fault| {
941
+ let mut scenario = Scenario::good();
942
+ a(&mut scenario);
943
+ b(&mut scenario);
944
+ scenario
945
+ .run(&verifier_for(&[&KEY_A]))
946
+ .expect_err("two faults")
947
+ .reason
948
+ };
949
+ (run(first, second), run(second, first))
950
+ }
951
+
952
+ #[test]
953
+ fn a_token_with_two_faults_reports_the_first_in_the_references_order() {
954
+ let pairs: [(Fault, Fault, Reason); 20] = [
955
+ (missing_cookie, state_mismatch, Reason::MissingCookie),
956
+ (missing_cookie, garbage_token, Reason::MissingCookie),
957
+ (state_mismatch, garbage_token, Reason::StateMismatch),
958
+ (state_mismatch, bad_signature, Reason::StateMismatch),
959
+ (too_large, bad_alg, Reason::TooLarge),
960
+ (bad_alg, bad_typ, Reason::BadAlg),
961
+ (bad_alg, unknown_kid, Reason::BadAlg),
962
+ (bad_typ, unknown_kid, Reason::Malformed),
963
+ (unknown_kid, bad_aud, Reason::UnknownKid),
964
+ (bad_signature, bad_aud, Reason::BadSignature),
965
+ (bad_signature, missing_claim, Reason::BadSignature),
966
+ (missing_claim, bad_iss, Reason::MissingClaim),
967
+ (bad_iss, bad_aud, Reason::BadIss),
968
+ (bad_aud, bad_env, Reason::BadAud),
969
+ (bad_env, float_exp, Reason::BadEnv),
970
+ (float_exp, not_yet_valid, Reason::Malformed),
971
+ (not_yet_valid, expired, Reason::NotYetValid),
972
+ (expired, long_lifetime, Reason::Expired),
973
+ (long_lifetime, nonce_mismatch, Reason::Lifetime),
974
+ (nonce_mismatch, replayed, Reason::NonceMismatch),
975
+ ];
976
+ for (first, second, expected) in pairs {
977
+ assert_eq!(
978
+ with_both(first, second),
979
+ (expected, expected),
980
+ "{expected:?} must win in either application order"
981
+ );
982
+ }
983
+ }
984
+
985
+ #[test]
986
+ fn the_identity_checks_come_last() {
987
+ assert_eq!(
988
+ with_both(replayed, unverified),
989
+ (Reason::Replay, Reason::Replay)
990
+ );
991
+ assert_eq!(
992
+ with_both(unverified, wrong_subject),
993
+ (Reason::EmailUnverified, Reason::EmailUnverified)
994
+ );
995
+ assert_eq!(
996
+ with_both(nonce_mismatch, unverified),
997
+ (Reason::NonceMismatch, Reason::NonceMismatch)
998
+ );
999
+ }
1000
+
1001
+ #[test]
1002
+ fn the_replay_check_spends_nothing_when_an_earlier_check_fails() {
1003
+ for fault in [
1004
+ state_mismatch,
1005
+ bad_alg,
1006
+ bad_signature,
1007
+ bad_aud,
1008
+ expired,
1009
+ nonce_mismatch,
1010
+ ] {
1011
+ let verifier = verifier_for(&[&KEY_A]);
1012
+ let mut scenario = Scenario::good();
1013
+ fault(&mut scenario);
1014
+ assert!(scenario.run(&verifier).is_err());
1015
+ assert!(
1016
+ verifier.jti_store().is_empty(NOW),
1017
+ "a refused request must not leave a spent jti"
1018
+ );
1019
+ }
1020
+ }
1021
+
1022
+ #[test]
1023
+ fn identity_failures_come_after_the_jti_is_spent() {
1024
+ let verifier = verifier_for(&[&KEY_A]);
1025
+ let mut scenario = Scenario::good();
1026
+ unverified(&mut scenario);
1027
+ assert_eq!(
1028
+ scenario.run(&verifier).unwrap_err().reason,
1029
+ Reason::EmailUnverified
1030
+ );
1031
+ assert_eq!(
1032
+ verifier.jti_store().len(NOW),
1033
+ 1,
1034
+ "the reference spends the jti before the identity checks"
1035
+ );
1036
+ }
1037
+
1038
+ // --- hostile input never panics --------------------------------------------------------------
1039
+
1040
+ #[test]
1041
+ fn every_single_character_mutation_of_a_good_token_is_answered_without_panicking() {
1042
+ let good = Scenario::good().token();
1043
+ let cookie = good_cookie();
1044
+ let replacements = ['A', '.', '=', '-', '_', ' ', '\u{0}', '\u{e9}', '\u{1f600}'];
1045
+ for position in (0..good.len()).step_by(7) {
1046
+ for replacement in replacements {
1047
+ if !good.is_char_boundary(position) {
1048
+ continue;
1049
+ }
1050
+ let mut mutated = good.clone();
1051
+ mutated.replace_range(position..position + 1, &replacement.to_string());
1052
+ let request = SigninRequest {
1053
+ token: Some(&mutated),
1054
+ state: Some(STATE),
1055
+ cookie: Some(&cookie),
1056
+ };
1057
+ let verdict = verifier_for(&[&KEY_A]).verify(&request, NOW);
1058
+ if mutated != good {
1059
+ assert!(
1060
+ verdict.is_err(),
1061
+ "a mutated token must never be accepted: position {position}"
1062
+ );
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ #[test]
1069
+ fn extreme_numbers_in_time_claims_are_refused_without_overflow() {
1070
+ let big = "99999999999999999999999999999999999999999999999";
1071
+ for claim in ["iat", "nbf", "exp"] {
1072
+ let claims = Scenario::good().claims;
1073
+ let mut text = Value::Object(claims).to_string();
1074
+ let needle = format!("\"{claim}\":");
1075
+ let start = text.find(&needle).unwrap() + needle.len();
1076
+ let end = start + text[start..].find([',', '}']).unwrap();
1077
+ text.replace_range(start..end, big);
1078
+ let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_A.kid()}).to_string();
1079
+ let token = assemble(&header, &text, &KEY_A);
1080
+ assert!(
1081
+ matches!(
1082
+ raw_token_reason(Some(&token)),
1083
+ Reason::NotYetValid | Reason::Lifetime | Reason::Expired
1084
+ ),
1085
+ "{claim}"
1086
+ );
1087
+ if claim == "nbf" {
1088
+ continue; // a far-past `nbf` is simply valid; only the other two have a negative extreme to refuse
1089
+ }
1090
+ let negative = text.replace(big, &format!("-{big}"));
1091
+ let token = assemble(&header, &negative, &KEY_A);
1092
+ assert!(
1093
+ matches!(
1094
+ raw_token_reason(Some(&token)),
1095
+ Reason::Expired | Reason::Lifetime
1096
+ ),
1097
+ "{claim}"
1098
+ );
1099
+ }
1100
+ }