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.
- checksums.yaml +4 -4
- 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.rb +1 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +16 -2
- data/lib/hecks/gate/stages.yml +32 -0
- 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/site/site_root/workflow.rb +5 -0
- 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/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/release_only.rb +111 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +15 -1
- data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
- data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
- data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json} +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- 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 +42 -4
- data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
- data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
//! A compact JWS taken apart without trusting any of it.
|
|
2
|
+
//!
|
|
3
|
+
//! Exactly three dot-separated segments, each canonical unpadded base64url, the first two JSON
|
|
4
|
+
//! objects with no repeated key. Nothing here checks a signature or a claim: a [`CompactJws`] only
|
|
5
|
+
//! says the text was structurally a JWS.
|
|
6
|
+
//!
|
|
7
|
+
//! ## Why the base64url is strict
|
|
8
|
+
//!
|
|
9
|
+
//! Standard decoders accept padding, the other alphabet, whitespace and spare trailing bits. Each
|
|
10
|
+
//! gives a second spelling of the same bytes, and a signature that verifies over one spelling must
|
|
11
|
+
//! not be re-spelled into a lookalike the issuer would never have produced. So a segment is
|
|
12
|
+
//! accepted
|
|
13
|
+
//! only if re-encoding the bytes it decodes to gives back exactly the segment.
|
|
14
|
+
|
|
15
|
+
use super::strict_json::{self, JsonObject};
|
|
16
|
+
|
|
17
|
+
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
18
|
+
|
|
19
|
+
/// Encodes bytes as unpadded base64url (RFC 4648 section 5).
|
|
20
|
+
pub fn encode_base64url(bytes: &[u8]) -> String {
|
|
21
|
+
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
|
22
|
+
for chunk in bytes.chunks(3) {
|
|
23
|
+
let word = chunk
|
|
24
|
+
.iter()
|
|
25
|
+
.enumerate()
|
|
26
|
+
.fold(0u32, |acc, (i, b)| acc | (*b as u32) << (16 - 8 * i));
|
|
27
|
+
let characters = chunk.len() + 1;
|
|
28
|
+
for i in 0..characters {
|
|
29
|
+
out.push(ALPHABET[((word >> (18 - 6 * i)) & 0x3f) as usize] as char);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Decodes canonical unpadded base64url.
|
|
36
|
+
///
|
|
37
|
+
/// Returns `None` for padding, any character outside the URL-safe alphabet, a length that no byte
|
|
38
|
+
/// string encodes to, and spare trailing bits that are not zero.
|
|
39
|
+
pub fn decode_base64url(text: &str) -> Option<Vec<u8>> {
|
|
40
|
+
if text.len() % 4 == 1 {
|
|
41
|
+
return None;
|
|
42
|
+
}
|
|
43
|
+
let mut out = Vec::with_capacity(text.len() / 4 * 3 + 2);
|
|
44
|
+
let (mut accumulator, mut bits) = (0u32, 0u32);
|
|
45
|
+
for byte in text.bytes() {
|
|
46
|
+
accumulator = accumulator << 6 | sextet(byte)?;
|
|
47
|
+
bits += 6;
|
|
48
|
+
if bits >= 8 {
|
|
49
|
+
bits -= 8;
|
|
50
|
+
out.push((accumulator >> bits) as u8);
|
|
51
|
+
accumulator &= (1 << bits) - 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Spare bits must be zero, or a second spelling would decode to the same bytes.
|
|
55
|
+
(accumulator == 0).then_some(out)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn sextet(byte: u8) -> Option<u32> {
|
|
59
|
+
ALPHABET
|
|
60
|
+
.iter()
|
|
61
|
+
.position(|candidate| *candidate == byte)
|
|
62
|
+
.map(|index| index as u32)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Why a token text was refused before any claim was read.
|
|
66
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
67
|
+
pub enum JwsFault {
|
|
68
|
+
/// Longer than the cap; refused without being parsed.
|
|
69
|
+
TooLarge,
|
|
70
|
+
/// Not structurally a JWS.
|
|
71
|
+
Malformed,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// A structurally valid compact JWS: the two JSON parts and the signed text.
|
|
75
|
+
pub struct CompactJws {
|
|
76
|
+
pub header: JsonObject,
|
|
77
|
+
pub claims: JsonObject,
|
|
78
|
+
pub signing_input: String,
|
|
79
|
+
pub signature_segment: String,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
impl CompactJws {
|
|
83
|
+
/// Parses a token text into its parts.
|
|
84
|
+
///
|
|
85
|
+
/// # Errors
|
|
86
|
+
///
|
|
87
|
+
/// Returns [`JwsFault::TooLarge`] when `text` is longer than `max_bytes`, checked before
|
|
88
|
+
/// anything else, and [`JwsFault::Malformed`] when it does not have exactly three segments,
|
|
89
|
+
/// when either of the first two is not canonical base64url of a JSON object, or when that
|
|
90
|
+
/// object repeats a key.
|
|
91
|
+
pub fn parse(text: &str, max_bytes: usize) -> Result<Self, JwsFault> {
|
|
92
|
+
if text.len() > max_bytes {
|
|
93
|
+
return Err(JwsFault::TooLarge);
|
|
94
|
+
}
|
|
95
|
+
let segments: Vec<&str> = text.split('.').collect();
|
|
96
|
+
let [header, claims, signature] = segments[..] else {
|
|
97
|
+
return Err(JwsFault::Malformed);
|
|
98
|
+
};
|
|
99
|
+
Ok(Self {
|
|
100
|
+
header: json_segment(header)?,
|
|
101
|
+
claims: json_segment(claims)?,
|
|
102
|
+
signing_input: format!("{header}.{claims}"),
|
|
103
|
+
signature_segment: signature.to_string(),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Returns the raw signature bytes, or `None` when the segment is not canonical base64url.
|
|
108
|
+
pub fn signature_bytes(&self) -> Option<Vec<u8>> {
|
|
109
|
+
decode_base64url(&self.signature_segment)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fn json_segment(segment: &str) -> Result<JsonObject, JwsFault> {
|
|
114
|
+
let bytes = decode_base64url(segment).ok_or(JwsFault::Malformed)?;
|
|
115
|
+
strict_json::parse_object(&bytes).ok_or(JwsFault::Malformed)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#[cfg(test)]
|
|
119
|
+
mod tests {
|
|
120
|
+
use super::*;
|
|
121
|
+
|
|
122
|
+
fn seg(text: &str) -> String {
|
|
123
|
+
encode_base64url(text.as_bytes())
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fn token(header: &str, claims: &str, signature: &str) -> String {
|
|
127
|
+
format!("{}.{}.{}", seg(header), seg(claims), signature)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[test]
|
|
131
|
+
fn base64url_round_trips_every_length_and_matches_known_encodings() {
|
|
132
|
+
assert_eq!(encode_base64url(b""), "");
|
|
133
|
+
assert_eq!(encode_base64url(b"f"), "Zg");
|
|
134
|
+
assert_eq!(encode_base64url(b"fo"), "Zm8");
|
|
135
|
+
assert_eq!(encode_base64url(b"foo"), "Zm9v");
|
|
136
|
+
assert_eq!(encode_base64url(&[0xfb, 0xff, 0xfe]), "-__-");
|
|
137
|
+
for len in 0..40usize {
|
|
138
|
+
let bytes: Vec<u8> = (0..len).map(|i| (i * 37 + 11) as u8).collect();
|
|
139
|
+
assert_eq!(decode_base64url(&encode_base64url(&bytes)), Some(bytes));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[test]
|
|
144
|
+
fn base64url_refuses_every_second_spelling() {
|
|
145
|
+
for text in [
|
|
146
|
+
"Zg==",
|
|
147
|
+
"Zg=",
|
|
148
|
+
"Zm9v+",
|
|
149
|
+
"Zm9v/",
|
|
150
|
+
"Zm 9v",
|
|
151
|
+
"Zm9v\n",
|
|
152
|
+
"Zm9",
|
|
153
|
+
"A",
|
|
154
|
+
"Zm9vY",
|
|
155
|
+
"ZB",
|
|
156
|
+
"Zh",
|
|
157
|
+
"Zm9\u{e9}",
|
|
158
|
+
"Zm9v\u{0}",
|
|
159
|
+
] {
|
|
160
|
+
assert_eq!(decode_base64url(text), None, "{text:?}");
|
|
161
|
+
}
|
|
162
|
+
assert_eq!(decode_base64url("Zg"), Some(b"f".to_vec()));
|
|
163
|
+
assert_eq!(decode_base64url("Zm8"), Some(b"fo".to_vec()));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#[test]
|
|
167
|
+
fn a_three_segment_token_of_two_objects_parses() {
|
|
168
|
+
let text = token(r#"{"alg":"EdDSA"}"#, r#"{"a":1}"#, "c2ln");
|
|
169
|
+
let jws = CompactJws::parse(&text, 4096).expect("parses");
|
|
170
|
+
assert_eq!(jws.header.get_str("alg"), Some("EdDSA"));
|
|
171
|
+
assert_eq!(jws.signature_segment, "c2ln");
|
|
172
|
+
assert_eq!(jws.signing_input, text.rsplit_once('.').unwrap().0);
|
|
173
|
+
assert_eq!(jws.signature_bytes(), Some(b"sig".to_vec()));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#[test]
|
|
177
|
+
fn the_wrong_number_of_segments_is_malformed() {
|
|
178
|
+
let two = format!("{}.{}", seg("{}"), seg("{}"));
|
|
179
|
+
let four = format!("{}.{}.c2ln.c2ln", seg("{}"), seg("{}"));
|
|
180
|
+
for text in [
|
|
181
|
+
"",
|
|
182
|
+
".",
|
|
183
|
+
"..",
|
|
184
|
+
"...",
|
|
185
|
+
"a",
|
|
186
|
+
"a.b",
|
|
187
|
+
two.as_str(),
|
|
188
|
+
four.as_str(),
|
|
189
|
+
] {
|
|
190
|
+
assert_eq!(
|
|
191
|
+
CompactJws::parse(text, 4096).err(),
|
|
192
|
+
Some(JwsFault::Malformed),
|
|
193
|
+
"{text:?}"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#[test]
|
|
199
|
+
fn non_canonical_non_json_and_non_object_parts_are_malformed() {
|
|
200
|
+
let ok = seg("{}");
|
|
201
|
+
let cases = [
|
|
202
|
+
format!("{}=.{ok}.c2ln", seg("{}")),
|
|
203
|
+
format!("{ok}.{}+.c2ln", seg("{}")),
|
|
204
|
+
format!("{}.{ok}.c2ln", seg("[]")),
|
|
205
|
+
format!("{ok}.{}.c2ln", seg("\"s\"")),
|
|
206
|
+
format!("{}.{ok}.c2ln", seg("not json")),
|
|
207
|
+
format!("{ok}.{}.c2ln", seg(r#"{"a":1,"a":2}"#)),
|
|
208
|
+
format!("{}.{ok}.c2ln", seg(r#"{"a":1,"a":1}"#)),
|
|
209
|
+
format!("{ok}..c2ln"),
|
|
210
|
+
format!(".{ok}.c2ln"),
|
|
211
|
+
];
|
|
212
|
+
for text in cases {
|
|
213
|
+
assert_eq!(
|
|
214
|
+
CompactJws::parse(&text, 4096).err(),
|
|
215
|
+
Some(JwsFault::Malformed),
|
|
216
|
+
"{text:?}"
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#[test]
|
|
222
|
+
fn a_non_canonical_signature_still_parses_but_has_no_bytes() {
|
|
223
|
+
let text = token("{}", "{}", "Zh");
|
|
224
|
+
let jws = CompactJws::parse(&text, 4096).expect("the structure is fine");
|
|
225
|
+
assert_eq!(jws.signature_bytes(), None);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[test]
|
|
229
|
+
fn the_size_cap_is_inclusive_and_checked_before_parsing() {
|
|
230
|
+
let text = token("{}", "{}", "c2ln");
|
|
231
|
+
assert!(CompactJws::parse(&text, text.len()).is_ok());
|
|
232
|
+
assert_eq!(
|
|
233
|
+
CompactJws::parse(&text, text.len() - 1).err(),
|
|
234
|
+
Some(JwsFault::TooLarge)
|
|
235
|
+
);
|
|
236
|
+
let garbage = "x".repeat(5000);
|
|
237
|
+
assert_eq!(
|
|
238
|
+
CompactJws::parse(&garbage, 4096).err(),
|
|
239
|
+
Some(JwsFault::TooLarge)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[test]
|
|
244
|
+
fn the_cap_counts_bytes_not_characters() {
|
|
245
|
+
let text = "\u{e9}".repeat(10);
|
|
246
|
+
assert_eq!(text.chars().count(), 10);
|
|
247
|
+
assert_eq!(CompactJws::parse(&text, 15).err(), Some(JwsFault::TooLarge));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
//! The known-answer vectors: tokens made by the issuer's own signer, and the verdict each must get.
|
|
2
|
+
//!
|
|
3
|
+
//! The file is a byte-for-byte copy of the issuer's vectors, pinned by hash so a change to either
|
|
4
|
+
//! copy fails loudly. Each single vector runs against a fresh single-use record; a sequence runs
|
|
5
|
+
//! its
|
|
6
|
+
//! steps against one record. The clock, the pinned issuer, site and environment, and the key set
|
|
7
|
+
//! all come from the file, and nothing touches the network.
|
|
8
|
+
|
|
9
|
+
use std::collections::HashSet;
|
|
10
|
+
|
|
11
|
+
use serde_json::Value;
|
|
12
|
+
use sha2::{Digest, Sha256};
|
|
13
|
+
|
|
14
|
+
use super::config::PlatformSigninConfig;
|
|
15
|
+
use super::jti::JtiStore;
|
|
16
|
+
use super::jwks::{parse_jwks, KeySource, VerifyingKey};
|
|
17
|
+
use super::test_support::{object_to_value, StaticKeys};
|
|
18
|
+
use super::verify::{Reason, SigninCookie, SigninRequest, Verifier};
|
|
19
|
+
|
|
20
|
+
const VECTORS_PATH: &str = concat!(
|
|
21
|
+
env!("CARGO_MANIFEST_DIR"),
|
|
22
|
+
"/tests/fixtures/platform_signin_vectors.json"
|
|
23
|
+
);
|
|
24
|
+
const PINNED_SHA256: &str = "09fd6d5780042657a7dc4892faa6fce45a652a6019b9e4d5bd3486280b54d94d";
|
|
25
|
+
|
|
26
|
+
fn bytes() -> Vec<u8> {
|
|
27
|
+
std::fs::read(VECTORS_PATH).expect("the vectors file is checked in")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn document() -> Value {
|
|
31
|
+
serde_json::from_slice(&bytes()).expect("the vectors file is JSON")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn hex(digest: &[u8]) -> String {
|
|
35
|
+
digest.iter().map(|b| format!("{b:02x}")).collect()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fn text<'a>(value: &'a Value, name: &str) -> &'a str {
|
|
39
|
+
value[name]
|
|
40
|
+
.as_str()
|
|
41
|
+
.unwrap_or_else(|| panic!("{name} is a string"))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn whole(value: &Value, name: &str) -> i64 {
|
|
45
|
+
value[name]
|
|
46
|
+
.as_i64()
|
|
47
|
+
.unwrap_or_else(|| panic!("{name} is an integer"))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fn verifier_for_document(doc: &Value) -> Verifier<StaticKeys> {
|
|
51
|
+
let pins = &doc["config"];
|
|
52
|
+
let mut config = PlatformSigninConfig::new(
|
|
53
|
+
text(pins, "iss"),
|
|
54
|
+
&format!("{}/.well-known/jwks.json", text(pins, "iss")),
|
|
55
|
+
text(pins, "aud"),
|
|
56
|
+
text(pins, "env"),
|
|
57
|
+
);
|
|
58
|
+
config.skew_seconds = whole(pins, "skew_seconds");
|
|
59
|
+
config.max_lifetime_seconds = whole(pins, "max_lifetime_seconds");
|
|
60
|
+
config.max_token_bytes = whole(pins, "max_token_bytes") as usize;
|
|
61
|
+
let parsed = parse_jwks(&doc["jwks"].to_string()).expect("the key set parses");
|
|
62
|
+
assert!(
|
|
63
|
+
parsed.refused.is_empty(),
|
|
64
|
+
"the key set holds only usable keys"
|
|
65
|
+
);
|
|
66
|
+
Verifier::new(config, StaticKeys(parsed.keys), JtiStore::new())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fn cookie_of(step: &Value) -> Option<SigninCookie> {
|
|
70
|
+
let cookie = step.get("cookie").filter(|c| !c.is_null())?;
|
|
71
|
+
let part = |name: &str| {
|
|
72
|
+
cookie
|
|
73
|
+
.get(name)
|
|
74
|
+
.and_then(Value::as_str)
|
|
75
|
+
.unwrap_or("")
|
|
76
|
+
.to_string()
|
|
77
|
+
};
|
|
78
|
+
Some(SigninCookie {
|
|
79
|
+
nonce: part("nonce"),
|
|
80
|
+
state: part("state"),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn run_step(verifier: &Verifier<StaticKeys>, step: &Value, now: i64, label: &str) {
|
|
85
|
+
let cookie = cookie_of(step);
|
|
86
|
+
let request = SigninRequest {
|
|
87
|
+
token: step["token"].as_str(),
|
|
88
|
+
state: step["state"].as_str(),
|
|
89
|
+
cookie: cookie.as_ref(),
|
|
90
|
+
};
|
|
91
|
+
let verdict = verifier.verify(&request, now);
|
|
92
|
+
match text(step, "expect") {
|
|
93
|
+
"accept" => {
|
|
94
|
+
let claims =
|
|
95
|
+
verdict.unwrap_or_else(|r| panic!("{label}: expected accept, got {}", r.code()));
|
|
96
|
+
assert_eq!(
|
|
97
|
+
object_to_value(&claims.all),
|
|
98
|
+
step["claims"],
|
|
99
|
+
"{label}: the verified claims"
|
|
100
|
+
);
|
|
101
|
+
assert_eq!(
|
|
102
|
+
claims.email,
|
|
103
|
+
text(step, "email"),
|
|
104
|
+
"{label}: the lowercased email"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
"refuse" => {
|
|
108
|
+
let refusal = verdict.err().unwrap_or_else(|| {
|
|
109
|
+
panic!(
|
|
110
|
+
"{label}: expected refusal {}, got accept",
|
|
111
|
+
text(step, "refusal")
|
|
112
|
+
)
|
|
113
|
+
});
|
|
114
|
+
assert_eq!(refusal.code(), text(step, "refusal"), "{label}");
|
|
115
|
+
}
|
|
116
|
+
other => panic!("{label}: unknown expectation {other}"),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#[test]
|
|
121
|
+
fn the_vectors_file_is_the_pinned_copy_byte_for_byte() {
|
|
122
|
+
assert_eq!(hex(&Sha256::digest(bytes())), PINNED_SHA256);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#[test]
|
|
126
|
+
fn the_file_declares_the_fixed_clock_and_the_issuers_documented_limits() {
|
|
127
|
+
let doc = document();
|
|
128
|
+
assert_eq!(whole(&doc, "version"), 1);
|
|
129
|
+
assert_eq!(whole(&doc, "now"), 1_800_000_000);
|
|
130
|
+
let pins = &doc["config"];
|
|
131
|
+
assert_eq!(
|
|
132
|
+
(
|
|
133
|
+
whole(pins, "skew_seconds"),
|
|
134
|
+
whole(pins, "max_lifetime_seconds"),
|
|
135
|
+
whole(pins, "max_token_bytes")
|
|
136
|
+
),
|
|
137
|
+
(30, 60, 4096)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[test]
|
|
142
|
+
fn every_vector_gets_exactly_its_recorded_verdict() {
|
|
143
|
+
let doc = document();
|
|
144
|
+
let now = whole(&doc, "now");
|
|
145
|
+
let vectors = doc["vectors"].as_array().expect("vectors");
|
|
146
|
+
assert!(!vectors.is_empty());
|
|
147
|
+
for vector in vectors {
|
|
148
|
+
let name = text(vector, "name");
|
|
149
|
+
let verifier = verifier_for_document(&doc);
|
|
150
|
+
match text(vector, "type") {
|
|
151
|
+
"single" => run_step(&verifier, vector, now, name),
|
|
152
|
+
"sequence" => {
|
|
153
|
+
for (index, step) in vector["steps"]
|
|
154
|
+
.as_array()
|
|
155
|
+
.expect("steps")
|
|
156
|
+
.iter()
|
|
157
|
+
.enumerate()
|
|
158
|
+
{
|
|
159
|
+
run_step(&verifier, step, now, &format!("{name} step {}", index + 1));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
other => panic!("{name}: unknown vector type {other}"),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn the_vectors_exercise_every_reason_and_both_verdicts() {
|
|
169
|
+
let doc = document();
|
|
170
|
+
let mut refusals: HashSet<String> = HashSet::new();
|
|
171
|
+
let mut accepts = 0;
|
|
172
|
+
for vector in doc["vectors"].as_array().unwrap() {
|
|
173
|
+
let steps: Vec<&Value> = match text(vector, "type") {
|
|
174
|
+
"sequence" => vector["steps"].as_array().unwrap().iter().collect(),
|
|
175
|
+
_ => vec![vector],
|
|
176
|
+
};
|
|
177
|
+
for step in steps {
|
|
178
|
+
match text(step, "expect") {
|
|
179
|
+
"accept" => accepts += 1,
|
|
180
|
+
_ => {
|
|
181
|
+
refusals.insert(text(step, "refusal").to_string());
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
assert!(accepts > 0);
|
|
187
|
+
for reason in Reason::ALL {
|
|
188
|
+
assert!(
|
|
189
|
+
refusals.contains(reason.code()),
|
|
190
|
+
"no vector refuses with {}",
|
|
191
|
+
reason.code()
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
assert!(
|
|
195
|
+
refusals
|
|
196
|
+
.iter()
|
|
197
|
+
.all(|code| Reason::from_code(code).is_some()),
|
|
198
|
+
"an unknown code in the file"
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#[test]
|
|
203
|
+
fn the_vectors_key_is_the_one_the_unit_tests_sign_with() {
|
|
204
|
+
let doc = document();
|
|
205
|
+
let parsed = parse_jwks(&doc["jwks"].to_string()).unwrap();
|
|
206
|
+
let key: VerifyingKey = parsed
|
|
207
|
+
.keys
|
|
208
|
+
.get(super::test_support::KID_A)
|
|
209
|
+
.cloned()
|
|
210
|
+
.expect("the documented kid");
|
|
211
|
+
assert_eq!(key.x(), super::test_support::X_A);
|
|
212
|
+
assert_eq!(
|
|
213
|
+
StaticKeys(parsed.keys).key_for(super::test_support::KID_A, 0),
|
|
214
|
+
Some(key)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn the_ordered_check_vectors_are_present_in_the_file() {
|
|
220
|
+
let doc = document();
|
|
221
|
+
let names: Vec<&str> = doc["vectors"]
|
|
222
|
+
.as_array()
|
|
223
|
+
.unwrap()
|
|
224
|
+
.iter()
|
|
225
|
+
.map(|v| text(v, "name"))
|
|
226
|
+
.collect();
|
|
227
|
+
for expected in [
|
|
228
|
+
"order_state_before_token",
|
|
229
|
+
"order_cookie_before_state",
|
|
230
|
+
"order_aud_before_time",
|
|
231
|
+
"order_iss_before_aud",
|
|
232
|
+
"order_signature_before_claims",
|
|
233
|
+
"order_time_before_nonce",
|
|
234
|
+
] {
|
|
235
|
+
assert!(names.contains(&expected), "{expected}");
|
|
236
|
+
}
|
|
237
|
+
}
|