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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
  3. data/lib/hecks/adapters/driven/script_runner.rb +93 -0
  4. data/lib/hecks/adapters/driven.rb +1 -0
  5. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
  6. data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
  7. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  8. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  9. data/lib/hecks/bluebook/assembly/marks.rb +3 -15
  10. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  11. data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
  12. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  13. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
  14. data/lib/hecks/bluebook/meta_validator.rb +28 -21
  15. data/lib/hecks/bluebook.rb +1 -0
  16. data/lib/hecks/deploy/bluebook/deploy.bluebook +17 -3
  17. data/lib/hecks/framework/bluebook/change.behaviors +30 -0
  18. data/lib/hecks/framework/bluebook/change.bluebook +162 -0
  19. data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
  20. data/lib/hecks/framework/change.oidc.json +34 -0
  21. data/lib/hecks/framework/console_settings.oidc.json +103 -0
  22. data/lib/hecks/framework/governance.oidc.json +28 -0
  23. data/lib/hecks/framework/identity.oidc.json +18 -0
  24. data/lib/hecks/framework/privacy.oidc.json +30 -0
  25. data/lib/hecks/gate/stages.yml +35 -0
  26. data/lib/hecks/grammar/translation.oidc.json +48 -0
  27. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
  28. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
  29. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
  30. data/lib/hecks/hecks/hecks.world +3 -0
  31. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  32. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  33. data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
  34. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  35. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  36. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  37. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  38. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  39. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  40. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  41. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  42. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  43. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  44. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  45. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  46. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  47. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
  48. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  49. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  50. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  51. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  52. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  53. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  54. data/lib/hecks/projections/deploy/box.rb +15 -3
  55. data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
  56. data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
  57. data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
  58. data/lib/hecks/projections/site/edge/checks.rb +2 -0
  59. data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
  60. data/lib/hecks/projections/site/edge/function_code.rb +86 -0
  61. data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
  62. data/lib/hecks/projections/site/edge.rb +35 -9
  63. data/lib/hecks/projections/site/site_cdn.rb +21 -7
  64. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  65. data/lib/hecks/projections/site/table/row_builder.rb +12 -2
  66. data/lib/hecks/projections/site/table.rb +4 -2
  67. data/lib/hecks/projector/cli_audience.rb +20 -0
  68. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  69. data/lib/hecks/rust_build/host.rb +11 -4
  70. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  71. data/lib/hecks/site/bluebook/site.bluebook +16 -1
  72. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  73. data/lib/hecks/tools/ci_gates.rb +20 -1
  74. data/lib/hecks/tools/lanes/files.rb +8 -0
  75. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  76. data/lib/hecks/tools/lanes.rb +1 -0
  77. data/lib/hecks/tools/oidc_manifests.rb +44 -6
  78. data/lib/hecks/tools/release_only.rb +111 -0
  79. data/lib/hecks/tools/site_routes/templates.rb +13 -9
  80. data/lib/hecks/version.rb +1 -1
  81. data/lib/hecks/vocabulary.rb +15 -1
  82. data/prebuilt/2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal +0 -0
  83. data/prebuilt/58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal +0 -0
  84. data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json} +1 -1
  85. data/rust/host/HECKS_RELEASE +1 -1
  86. data/rust/host/src/auth.rs +58 -0
  87. data/rust/host/src/extension.rs +13 -1
  88. data/rust/host/src/lib.rs +1 -0
  89. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  90. data/rust/host/src/platform_signin/config.rs +512 -0
  91. data/rust/host/src/platform_signin/cookie.rs +237 -0
  92. data/rust/host/src/platform_signin/extension.rs +190 -0
  93. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  94. data/rust/host/src/platform_signin/handler.rs +251 -0
  95. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  96. data/rust/host/src/platform_signin/jti.rs +196 -0
  97. data/rust/host/src/platform_signin/jwks.rs +542 -0
  98. data/rust/host/src/platform_signin/jws.rs +249 -0
  99. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  100. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  101. data/rust/host/src/platform_signin/mod.rs +75 -0
  102. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  103. data/rust/host/src/platform_signin/pages.rs +204 -0
  104. data/rust/host/src/platform_signin/query.rs +233 -0
  105. data/rust/host/src/platform_signin/session.rs +151 -0
  106. data/rust/host/src/platform_signin/settings.rs +385 -0
  107. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  108. data/rust/host/src/platform_signin/test_support.rs +235 -0
  109. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  110. data/rust/host/src/platform_signin/verify.rs +604 -0
  111. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  112. data/rust/host/src/web.rs +50 -6
  113. data/rust/parser/src/parse/value_object.rs +22 -10
  114. metadata +59 -7
  115. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  116. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
  117. /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
  118. /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +0 -0
@@ -0,0 +1,542 @@
1
+ //! The issuer's public keys: parsing its key set, and a cache that fetches it politely.
2
+ //!
3
+ //! ## Keys are named by what they are
4
+ //!
5
+ //! Every key is indexed by the RFC 7638 thumbprint computed from its own material, never by the
6
+ //! `kid` the document claims. A token's `kid` header is then only a way to ask "the key whose
7
+ //! thumbprint is this"; a document that labels one key with another's identifier cannot make a
8
+ //! token verify under the wrong key. Only `OKP` / `Ed25519` keys with a 32-byte canonical `x` and
9
+ //! no
10
+ //! private member `d` are kept; everything else in the document is dropped and reported.
11
+ //!
12
+ //! ## No network here
13
+ //!
14
+ //! The cache asks an injected [`JwksFetcher`] for the document body. The real HTTP implementation
15
+ //! (timeout, body cap, no redirects) belongs to whoever wires this module in.
16
+
17
+ use std::collections::HashMap;
18
+ use std::sync::Mutex;
19
+
20
+ use sha2::{Digest, Sha256};
21
+
22
+ use super::jws::{decode_base64url, encode_base64url};
23
+ use super::strict_json::{self, Json};
24
+
25
+ /// Seconds a fetched key set is served before it is considered stale.
26
+ pub const DEFAULT_TTL_SECONDS: i64 = 300;
27
+ /// The fewest seconds between two fetch attempts, however many unknown keys arrive.
28
+ pub const DEFAULT_MIN_REFETCH_SECONDS: i64 = 30;
29
+ /// The longest time to live a fetcher's hint can stretch the cache to.
30
+ pub const MAX_TTL_SECONDS: i64 = 3600;
31
+ /// The size of an Ed25519 public key in bytes.
32
+ pub const ED25519_PUBLIC_KEY_BYTES: usize = 32;
33
+
34
+ /// An Ed25519 public key the issuer publishes, kept as its canonical `x` member.
35
+ #[derive(Debug, Clone, PartialEq, Eq)]
36
+ pub struct VerifyingKey {
37
+ x: String,
38
+ }
39
+
40
+ impl VerifyingKey {
41
+ /// Builds a key from the JWK `x` member, or `None` unless it is canonical base64url of 32
42
+ /// bytes.
43
+ pub fn from_x(x: &str) -> Option<Self> {
44
+ let raw = decode_base64url(x)?;
45
+ (raw.len() == ED25519_PUBLIC_KEY_BYTES).then(|| Self { x: x.to_string() })
46
+ }
47
+
48
+ /// Returns the `x` member, canonical unpadded base64url.
49
+ pub fn x(&self) -> &str {
50
+ &self.x
51
+ }
52
+
53
+ /// Returns the RFC 7638 thumbprint of this key, which is its identifier.
54
+ pub fn thumbprint(&self) -> String {
55
+ thumbprint(&self.x)
56
+ }
57
+ }
58
+
59
+ /// Computes the RFC 7638 thumbprint of an Ed25519 key from its `x` member.
60
+ ///
61
+ /// The thumbprint is the unpadded base64url SHA-256 of the JSON object with members `crv`, `kty`
62
+ /// and `x`, in that lexicographic order, with no whitespace.
63
+ pub fn thumbprint(x: &str) -> String {
64
+ let canonical = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{x}"}}"#);
65
+ encode_base64url(&Sha256::digest(canonical.as_bytes()))
66
+ }
67
+
68
+ /// Why one entry of a key set was not kept.
69
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
70
+ pub enum KeyRefusal {
71
+ NotAnObject,
72
+ NotOkp,
73
+ NotEd25519,
74
+ BadX,
75
+ CarriesPrivateKey,
76
+ }
77
+
78
+ /// What a key set document held.
79
+ #[derive(Debug, Default, PartialEq, Eq)]
80
+ pub struct ParsedKeys {
81
+ /// The usable keys, by computed thumbprint.
82
+ pub keys: HashMap<String, VerifyingKey>,
83
+ /// One entry per document entry that was dropped.
84
+ pub refused: Vec<KeyRefusal>,
85
+ }
86
+
87
+ /// A key set document that could not be read at all.
88
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
89
+ pub struct JwksMalformed;
90
+
91
+ /// Reads a key set document.
92
+ ///
93
+ /// # Errors
94
+ ///
95
+ /// Returns [`JwksMalformed`] when the body is not one strict JSON object (a repeated key counts) or
96
+ /// has no `keys` array. Individual bad entries are not errors: they are dropped and listed in
97
+ /// [`ParsedKeys::refused`].
98
+ pub fn parse_jwks(body: &str) -> Result<ParsedKeys, JwksMalformed> {
99
+ let document = strict_json::parse_object(body.as_bytes()).ok_or(JwksMalformed)?;
100
+ let Some(Json::Array(entries)) = document.get("keys") else {
101
+ return Err(JwksMalformed);
102
+ };
103
+ let mut parsed = ParsedKeys::default();
104
+ for entry in entries {
105
+ match key_from_entry(entry) {
106
+ Ok(key) => {
107
+ parsed.keys.insert(key.thumbprint(), key);
108
+ }
109
+ Err(refusal) => parsed.refused.push(refusal),
110
+ }
111
+ }
112
+ Ok(parsed)
113
+ }
114
+
115
+ fn key_from_entry(entry: &Json) -> Result<VerifyingKey, KeyRefusal> {
116
+ let Json::Object(jwk) = entry else {
117
+ return Err(KeyRefusal::NotAnObject);
118
+ };
119
+ // A private member means the issuer published a secret; the entry is never trusted or indexed.
120
+ if jwk.get("d").is_some() {
121
+ return Err(KeyRefusal::CarriesPrivateKey);
122
+ }
123
+ if jwk.get_str("kty") != Some("OKP") {
124
+ return Err(KeyRefusal::NotOkp);
125
+ }
126
+ if jwk.get_str("crv") != Some("Ed25519") {
127
+ return Err(KeyRefusal::NotEd25519);
128
+ }
129
+ jwk.get_str("x")
130
+ .and_then(VerifyingKey::from_x)
131
+ .ok_or(KeyRefusal::BadX)
132
+ }
133
+
134
+ /// A failed attempt to get the key set document. It carries no detail on purpose.
135
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
136
+ pub struct FetchFailed;
137
+
138
+ /// The one thing the cache needs from the network: the key set document's body.
139
+ pub trait JwksFetcher: Send + Sync {
140
+ /// Fetches the issuer's key set document.
141
+ ///
142
+ /// # Errors
143
+ ///
144
+ /// Returns [`FetchFailed`] for any failure; the cache treats every failure alike.
145
+ fn fetch(&self) -> Result<String, FetchFailed>;
146
+
147
+ /// How long the issuer allows the last fetched document to be cached, in seconds, if it said.
148
+ ///
149
+ /// The cache honours it between its refetch interval and [`MAX_TTL_SECONDS`]; `None` keeps the
150
+ /// cache's own time to live.
151
+ fn ttl_hint(&self) -> Option<i64> {
152
+ None
153
+ }
154
+ }
155
+
156
+ /// Where a verifier gets the public key for a token's `kid`.
157
+ pub trait KeySource {
158
+ /// Returns the key whose thumbprint is `kid`, as of `now` (Unix seconds).
159
+ fn key_for(&self, kid: &str, now: i64) -> Option<VerifyingKey>;
160
+ }
161
+
162
+ #[derive(Default)]
163
+ struct CacheState {
164
+ keys: HashMap<String, VerifyingKey>,
165
+ fetched_at: Option<i64>,
166
+ attempted_at: Option<i64>,
167
+ /// The time to live the last successful fetch earned, when the issuer named one.
168
+ hinted_ttl: Option<i64>,
169
+ }
170
+
171
+ /// A key set cache with a time to live and a rate-limited refetch on an unknown key.
172
+ ///
173
+ /// An unknown `kid` causes at most one fetch per minimum interval, however many unknown ones
174
+ /// arrive, and a failed fetch counts as an attempt: someone spraying random identifiers must not
175
+ /// turn the verifier into a fetch amplifier. A failed fetch leaves the old keys to age out at their
176
+ /// time to live.
177
+ pub struct JwksCache<F: JwksFetcher> {
178
+ fetcher: F,
179
+ ttl_seconds: i64,
180
+ min_refetch_seconds: i64,
181
+ state: Mutex<CacheState>,
182
+ }
183
+
184
+ impl<F: JwksFetcher> JwksCache<F> {
185
+ /// Builds a cache with the default time to live (300 s) and refetch interval (30 s).
186
+ pub fn new(fetcher: F) -> Self {
187
+ Self::with_timing(fetcher, DEFAULT_TTL_SECONDS, DEFAULT_MIN_REFETCH_SECONDS)
188
+ }
189
+
190
+ /// Builds a cache with explicit timing, in seconds.
191
+ pub fn with_timing(fetcher: F, ttl_seconds: i64, min_refetch_seconds: i64) -> Self {
192
+ Self {
193
+ fetcher,
194
+ ttl_seconds,
195
+ min_refetch_seconds,
196
+ state: Mutex::new(CacheState::default()),
197
+ }
198
+ }
199
+
200
+ fn lock(&self) -> std::sync::MutexGuard<'_, CacheState> {
201
+ // The state is only ever replaced whole, so a panic elsewhere cannot leave it half-written.
202
+ self.state
203
+ .lock()
204
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
205
+ }
206
+
207
+ fn is_stale(&self, state: &CacheState, now: i64) -> bool {
208
+ let ttl = state.hinted_ttl.unwrap_or(self.ttl_seconds);
209
+ state
210
+ .fetched_at
211
+ .is_none_or(|fetched| now.saturating_sub(fetched) >= ttl)
212
+ }
213
+
214
+ /// The issuer's hint, kept between the refetch interval and the cap so that neither a zero nor
215
+ /// a huge value can turn the cache into a fetch amplifier or a stale-key hazard.
216
+ fn bounded_hint(&self) -> Option<i64> {
217
+ self.fetcher
218
+ .ttl_hint()
219
+ .map(|hint| hint.clamp(self.min_refetch_seconds, MAX_TTL_SECONDS))
220
+ }
221
+
222
+ fn refresh_due(&self, state: &CacheState, kid: &str, now: i64) -> bool {
223
+ let Some(attempted) = state.attempted_at else {
224
+ return true;
225
+ };
226
+ let wanted = self.is_stale(state, now) || !state.keys.contains_key(kid);
227
+ wanted && now.saturating_sub(attempted) >= self.min_refetch_seconds
228
+ }
229
+
230
+ /// A failed fetch still counts as an attempt and leaves the old keys to age out.
231
+ fn refresh(&self, state: &mut CacheState, now: i64) {
232
+ state.attempted_at = Some(now);
233
+ let parsed = self
234
+ .fetcher
235
+ .fetch()
236
+ .ok()
237
+ .and_then(|body| parse_jwks(&body).ok());
238
+ if let Some(parsed) = parsed {
239
+ state.keys = parsed.keys;
240
+ state.fetched_at = Some(now);
241
+ state.hinted_ttl = self.bounded_hint();
242
+ }
243
+ }
244
+ }
245
+
246
+ impl<F: JwksFetcher> KeySource for JwksCache<F> {
247
+ fn key_for(&self, kid: &str, now: i64) -> Option<VerifyingKey> {
248
+ let mut state = self.lock();
249
+ if self.refresh_due(&state, kid, now) {
250
+ self.refresh(&mut state, now);
251
+ }
252
+ if self.is_stale(&state, now) {
253
+ return None;
254
+ }
255
+ state.keys.get(kid).cloned()
256
+ }
257
+ }
258
+
259
+ #[cfg(test)]
260
+ mod tests {
261
+ use super::*;
262
+ use crate::platform_signin::test_support::*;
263
+ use std::collections::VecDeque;
264
+ use std::sync::atomic::{AtomicUsize, Ordering};
265
+ use std::sync::Arc;
266
+
267
+ #[test]
268
+ fn the_thumbprint_of_the_known_answer_key_is_the_documents_kid() {
269
+ assert_eq!(thumbprint(X_A), KID_A);
270
+ assert_eq!(VerifyingKey::from_x(X_A).unwrap().thumbprint(), KID_A);
271
+ }
272
+
273
+ #[test]
274
+ fn the_thumbprint_hashes_the_members_in_rfc_7638_order() {
275
+ use sha2::{Digest, Sha256};
276
+ let text = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{X_B}"}}"#);
277
+ assert_eq!(
278
+ thumbprint(X_B),
279
+ encode_base64url(&Sha256::digest(text.as_bytes()))
280
+ );
281
+ }
282
+
283
+ #[test]
284
+ fn a_key_needs_a_canonical_32_byte_x() {
285
+ assert!(VerifyingKey::from_x(X_A).is_some());
286
+ let short = encode_base64url(&[1u8; 31]);
287
+ let long = encode_base64url(&[1u8; 33]);
288
+ let mut spare_bits = X_A.to_string();
289
+ spare_bits.pop();
290
+ spare_bits.push('l');
291
+ for x in [
292
+ short.as_str(),
293
+ long.as_str(),
294
+ "",
295
+ "!!",
296
+ &format!("{X_A}="),
297
+ spare_bits.as_str(),
298
+ ] {
299
+ assert!(VerifyingKey::from_x(x).is_none(), "{x:?}");
300
+ }
301
+ }
302
+
303
+ fn document(entries: &[String]) -> String {
304
+ format!(r#"{{"keys":[{}]}}"#, entries.join(","))
305
+ }
306
+
307
+ fn jwk(extra: &str) -> String {
308
+ format!(r#"{{"kty":"OKP","crv":"Ed25519","x":"{X_A}"{extra}}}"#)
309
+ }
310
+
311
+ #[test]
312
+ fn a_good_document_is_indexed_by_the_computed_thumbprint() {
313
+ let parsed = parse_jwks(&document(&[jwk(&format!(
314
+ r#","kid":"{KID_A}","alg":"EdDSA","use":"sig""#
315
+ ))]))
316
+ .unwrap();
317
+ assert_eq!(
318
+ parsed.keys.keys().collect::<Vec<_>>(),
319
+ vec![&KID_A.to_string()]
320
+ );
321
+ assert!(parsed.refused.is_empty());
322
+ }
323
+
324
+ #[test]
325
+ fn a_claimed_kid_never_names_a_key() {
326
+ let parsed = parse_jwks(&document(&[jwk(r#","kid":"claimed-by-the-document""#)])).unwrap();
327
+ assert!(!parsed.keys.contains_key("claimed-by-the-document"));
328
+ assert_eq!(
329
+ parsed.keys.get(KID_A).map(|k| k.x().to_string()),
330
+ Some(X_A.to_string())
331
+ );
332
+ }
333
+
334
+ #[test]
335
+ fn a_document_labelling_one_key_with_anothers_kid_cannot_swap_them() {
336
+ let swapped = format!(r#"{{"kty":"OKP","crv":"Ed25519","x":"{X_B}","kid":"{KID_A}"}}"#);
337
+ let parsed = parse_jwks(&document(&[swapped])).unwrap();
338
+ assert!(!parsed.keys.contains_key(KID_A));
339
+ assert_eq!(parsed.keys.get(&thumbprint(X_B)).unwrap().x(), X_B);
340
+ }
341
+
342
+ #[test]
343
+ fn every_entry_that_is_not_a_plain_ed25519_public_key_is_dropped_and_reported() {
344
+ let entries = [
345
+ (
346
+ r#"{"kty":"RSA","n":"AQAB","e":"AQAB"}"#.to_string(),
347
+ KeyRefusal::NotOkp,
348
+ ),
349
+ (
350
+ format!(r#"{{"kty":"EC","crv":"P-256","x":"{X_A}"}}"#),
351
+ KeyRefusal::NotOkp,
352
+ ),
353
+ (
354
+ format!(r#"{{"kty":"OKP","crv":"Ed448","x":"{X_A}"}}"#),
355
+ KeyRefusal::NotEd25519,
356
+ ),
357
+ (
358
+ format!(r#"{{"kty":"OKP","crv":"X25519","x":"{X_A}"}}"#),
359
+ KeyRefusal::NotEd25519,
360
+ ),
361
+ (
362
+ r#"{"kty":"OKP","crv":"Ed25519"}"#.to_string(),
363
+ KeyRefusal::BadX,
364
+ ),
365
+ (
366
+ r#"{"kty":"OKP","crv":"Ed25519","x":"AAAA"}"#.to_string(),
367
+ KeyRefusal::BadX,
368
+ ),
369
+ (
370
+ r#"{"kty":"OKP","crv":"Ed25519","x":7}"#.to_string(),
371
+ KeyRefusal::BadX,
372
+ ),
373
+ (jwk(r#","d":"c2VjcmV0""#), KeyRefusal::CarriesPrivateKey),
374
+ ("[]".to_string(), KeyRefusal::NotAnObject),
375
+ ("\"x\"".to_string(), KeyRefusal::NotAnObject),
376
+ ];
377
+ for (entry, why) in entries {
378
+ let parsed = parse_jwks(&document(std::slice::from_ref(&entry))).unwrap();
379
+ assert!(parsed.keys.is_empty(), "{entry}");
380
+ assert_eq!(parsed.refused, vec![why], "{entry}");
381
+ }
382
+ }
383
+
384
+ #[test]
385
+ fn a_private_key_in_the_document_does_not_poison_its_good_neighbours() {
386
+ let parsed = parse_jwks(&document(&[
387
+ jwk(r#","d":"c2VjcmV0""#),
388
+ format!(r#"{{"kty":"OKP","crv":"Ed25519","x":"{X_B}"}}"#),
389
+ ]))
390
+ .unwrap();
391
+ assert_eq!(parsed.keys.len(), 1);
392
+ assert!(parsed.keys.contains_key(&thumbprint(X_B)));
393
+ assert_eq!(parsed.refused, vec![KeyRefusal::CarriesPrivateKey]);
394
+ }
395
+
396
+ #[test]
397
+ fn a_document_that_is_not_a_strict_object_with_a_keys_array_is_malformed() {
398
+ let duplicate = format!(r#"{{"keys":[],"keys":[{}]}}"#, jwk(""));
399
+ for body in [
400
+ "",
401
+ "[]",
402
+ "null",
403
+ "{}",
404
+ r#"{"keys":{}}"#,
405
+ r#"{"keys":"x"}"#,
406
+ "not json",
407
+ duplicate.as_str(),
408
+ ] {
409
+ assert_eq!(parse_jwks(body), Err(JwksMalformed), "{body:?}");
410
+ }
411
+ assert!(parse_jwks(r#"{"keys":[]}"#).unwrap().keys.is_empty());
412
+ }
413
+
414
+ #[test]
415
+ fn a_repeated_member_inside_a_key_makes_the_document_malformed() {
416
+ let body = document(&[format!(
417
+ r#"{{"kty":"OKP","crv":"Ed25519","x":"{X_A}","x":"{X_B}"}}"#
418
+ )]);
419
+ assert_eq!(parse_jwks(&body), Err(JwksMalformed));
420
+ }
421
+
422
+ struct Canned {
423
+ script: Mutex<VecDeque<Result<String, FetchFailed>>>,
424
+ calls: Arc<AtomicUsize>,
425
+ }
426
+
427
+ impl JwksFetcher for Canned {
428
+ fn fetch(&self) -> Result<String, FetchFailed> {
429
+ self.calls.fetch_add(1, Ordering::SeqCst);
430
+ let mut script = self.script.lock().unwrap();
431
+ if script.len() > 1 {
432
+ script.pop_front().unwrap()
433
+ } else {
434
+ script.front().cloned().unwrap_or(Err(FetchFailed))
435
+ }
436
+ }
437
+ }
438
+
439
+ fn cache_of(script: Vec<Result<String, FetchFailed>>) -> (JwksCache<Canned>, Arc<AtomicUsize>) {
440
+ let calls = Arc::new(AtomicUsize::new(0));
441
+ let fetcher = Canned {
442
+ script: Mutex::new(script.into()),
443
+ calls: calls.clone(),
444
+ };
445
+ (JwksCache::new(fetcher), calls)
446
+ }
447
+
448
+ fn one_key(x: &str) -> Result<String, FetchFailed> {
449
+ Ok(document(&[format!(
450
+ r#"{{"kty":"OKP","crv":"Ed25519","x":"{x}"}}"#
451
+ )]))
452
+ }
453
+
454
+ #[test]
455
+ fn nothing_is_fetched_until_a_key_is_asked_for_and_a_known_key_is_served_from_memory() {
456
+ let (cache, calls) = cache_of(vec![one_key(X_A)]);
457
+ assert_eq!(calls.load(Ordering::SeqCst), 0);
458
+ assert!(cache.key_for(KID_A, NOW).is_some());
459
+ assert!(cache.key_for(KID_A, NOW + 100).is_some());
460
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
461
+ }
462
+
463
+ #[test]
464
+ fn an_unknown_kid_causes_at_most_one_fetch_per_interval_however_many_arrive() {
465
+ let (cache, calls) = cache_of(vec![one_key(X_A)]);
466
+ assert!(cache.key_for(KID_A, NOW).is_some());
467
+ for i in 0..50 {
468
+ assert!(cache
469
+ .key_for(&format!("unknown-{i}"), NOW + 1 + (i % 28))
470
+ .is_none());
471
+ }
472
+ assert_eq!(
473
+ calls.load(Ordering::SeqCst),
474
+ 1,
475
+ "all inside the 30 second interval"
476
+ );
477
+ assert!(cache.key_for("unknown-later", NOW + 30).is_none());
478
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
479
+ assert!(cache.key_for("unknown-again", NOW + 31).is_none());
480
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
481
+ }
482
+
483
+ #[test]
484
+ fn a_failed_fetch_counts_as_an_attempt() {
485
+ let (cache, calls) = cache_of(vec![Err(FetchFailed)]);
486
+ for i in 0..20 {
487
+ assert!(cache.key_for(KID_A, NOW + i).is_none());
488
+ }
489
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
490
+ assert!(cache.key_for(KID_A, NOW + 30).is_none());
491
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
492
+ }
493
+
494
+ #[test]
495
+ fn a_failed_refetch_keeps_the_old_keys_until_their_time_to_live_ends() {
496
+ let (cache, calls) = cache_of(vec![one_key(X_A), Err(FetchFailed)]);
497
+ assert!(cache.key_for(KID_A, NOW).is_some());
498
+ assert!(cache.key_for("unknown", NOW + 31).is_none());
499
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
500
+ assert!(
501
+ cache.key_for(KID_A, NOW + 60).is_some(),
502
+ "old key still served"
503
+ );
504
+ assert!(cache.key_for(KID_A, NOW + 299).is_some());
505
+ assert!(
506
+ cache.key_for(KID_A, NOW + 300).is_none(),
507
+ "stale at the time to live, and the refetch failed"
508
+ );
509
+ }
510
+
511
+ #[test]
512
+ fn a_stale_cache_refetches_on_the_next_use() {
513
+ let (cache, calls) = cache_of(vec![one_key(X_A)]);
514
+ assert!(cache.key_for(KID_A, NOW).is_some());
515
+ assert!(cache.key_for(KID_A, NOW + 300).is_some());
516
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
517
+ }
518
+
519
+ #[test]
520
+ fn rotation_a_previous_key_is_served_while_listed_and_refused_once_removed() {
521
+ let both = document(&[
522
+ format!(r#"{{"kty":"OKP","crv":"Ed25519","x":"{X_A}"}}"#),
523
+ format!(r#"{{"kty":"OKP","crv":"Ed25519","x":"{X_B}"}}"#),
524
+ ]);
525
+ let (cache, _) = cache_of(vec![Ok(both), one_key(X_B)]);
526
+ assert!(cache.key_for(KID_A, NOW).is_some());
527
+ assert!(cache.key_for(&thumbprint(X_B), NOW).is_some());
528
+ assert!(
529
+ cache.key_for(KID_A, NOW + 300).is_none(),
530
+ "refetched document no longer lists it"
531
+ );
532
+ assert!(cache.key_for(&thumbprint(X_B), NOW + 300).is_some());
533
+ }
534
+
535
+ #[test]
536
+ fn a_malformed_document_is_a_failed_fetch_and_keeps_the_old_keys() {
537
+ let (cache, _) = cache_of(vec![one_key(X_A), Ok("not json".to_string())]);
538
+ assert!(cache.key_for(KID_A, NOW).is_some());
539
+ assert!(cache.key_for("unknown", NOW + 31).is_none());
540
+ assert!(cache.key_for(KID_A, NOW + 40).is_some());
541
+ }
542
+ }