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,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
|
+
}
|