Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/acme/jose.rs

30.0 KiB, 77 runs

created by r1870400018:9521, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! JSON Web Signature (JWS) primitives for ACME, using ES256.
2//!
3//! ACME RFC 8555 §6.2 requires every authenticated request to the directory to
4//! be wrapped in a JWS in the flattened JSON serialisation form defined by RFC
5//! 7515 §7.2.2. Let's Encrypt and other common CAs accept account keys signed
6//! with ES256 (ECDSA using P-256 and SHA-256), which is what this module
7//! implements.
8//!
9//! The module deliberately does not know anything ACME-specific. It exposes a
10//! [`JwsSigner`] that:
11//!
12//! - Generates or loads an ES256 key pair.
13//! - Returns its PKCS#8 bytes for on-disk persistence via [`JwsSigner::pkcs8_bytes`].
14//! - Exposes the public key as a JWK in [`JwsSigner::public_jwk`].
15//! - Computes the RFC 7638 §3 thumbprint of the public JWK via
16//! [`JwsSigner::jwk_thumbprint_sha256`].
17//! - Signs a caller-supplied protected header and payload via
18//! [`JwsSigner::sign_flattened`], returning the resulting flattened JWS as a
19//! `Dat::Map` ready to be serialised for an HTTP request body.
20//!
21//! Callers of this module -- the higher-level ACME client -- build the
22//! protected header themselves (filling in `alg`, `url`, `nonce` and either
23//! `jwk` or `kid`), pass it in, and receive the signed structure back.
24//!
25//! The signature is produced by `ring` using
26//! `ECDSA_P256_SHA256_FIXED_SIGNING`, which emits the IEEE P1363 fixed-width
27//! form (64 bytes: `r || s`) that JWS requires. No ASN.1 to P1363 conversion
28//! is needed on our side.
29//!
30//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
31//! Anthropic Claude
32
33use oxedyne_fe2o3_core::prelude::*;
34use oxedyne_fe2o3_jdat::prelude::*;
35
36use std::fmt;
37
38use base64;
39use ring::{
40 digest::{
41 Context,
42 SHA256,
43 },
44 rand::SystemRandom,
45 signature::{
46 EcdsaKeyPair,
47 KeyPair,
48 ECDSA_P256_SHA256_FIXED_SIGNING,
49 },
50};
51
52
53// ┌───────────────────────────────────────────────────────────────────────────┐
54// │ JWS SIGNER │
55// └───────────────────────────────────────────────────────────────────────────┘
56
57/// An ES256 JWS signer.
58///
59/// `ring` consumes the PKCS#8 bytes during load and does not expose them
60/// afterwards, so a copy is retained here to persist the key to disk and reload
61/// it via [`JwsSigner::from_pkcs8`].
62pub struct JwsSigner {
63 pkcs8: Vec<u8>, // retained for persistence
64 key_pair: EcdsaKeyPair, // ring's live signing handle
65 rng: SystemRandom, // for the non-deterministic part of ECDSA
66}
67
68impl fmt::Debug for JwsSigner {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 f.debug_struct("JwsSigner")
71 .field("pkcs8", &"<redacted>")
72 .field("key_pair", &"<redacted>")
73 .field("rng", &"SystemRandom")
74 .finish()
75 }
76}
77
78impl JwsSigner {
79
80 pub fn new_es256() -> Outcome<Self> {
81 let rng = SystemRandom::new();
82 let pkcs8 = match EcdsaKeyPair::generate_pkcs8(
83 &ECDSA_P256_SHA256_FIXED_SIGNING,
84 &rng,
85 ) {
86 Ok(doc) => doc.as_ref().to_vec(),
87 Err(_) => return Err(err!(
88 "ring::signature::EcdsaKeyPair::generate_pkcs8 failed to \
89 produce a fresh ES256 key pair.";
90 Init, Unknown)),
91 };
92 let key_pair = match EcdsaKeyPair::from_pkcs8(
93 &ECDSA_P256_SHA256_FIXED_SIGNING,
94 &pkcs8,
95 &rng,
96 ) {
97 Ok(kp) => kp,
98 Err(e) => return Err(err!(
99 "ring::signature::EcdsaKeyPair::from_pkcs8 rejected the \
100 freshly-generated PKCS#8 document: {}.", e;
101 Init, Invalid)),
102 };
103 Ok(Self {
104 pkcs8,
105 key_pair,
106 rng,
107 })
108 }
109
110 pub fn from_pkcs8(pkcs8: &[u8]) -> Outcome<Self> {
111 let rng = SystemRandom::new();
112 let key_pair = match EcdsaKeyPair::from_pkcs8(
113 &ECDSA_P256_SHA256_FIXED_SIGNING,
114 pkcs8,
115 &rng,
116 ) {
117 Ok(kp) => kp,
118 Err(e) => return Err(err!(
119 "ring::signature::EcdsaKeyPair::from_pkcs8 rejected the \
120 supplied PKCS#8 bytes: {}.", e;
121 Init, Invalid, Input)),
122 };
123 Ok(Self {
124 pkcs8: pkcs8.to_vec(),
125 key_pair,
126 rng,
127 })
128 }
129
130 /// The bytes round-trip through [`JwsSigner::from_pkcs8`].
131 pub fn pkcs8_bytes(&self) -> &[u8] {
132 &self.pkcs8
133 }
134
135 /// A `Dat::Map` with the keys `kty`, `crv`, `x` and `y`, ready to embed in a
136 /// JWS protected header. RFC 8555 §6.2 requires `jwk` when registering a new
137 /// account; authenticated follow-up requests carry `kid` instead.
138 pub fn public_jwk(&self) -> Outcome<Dat> {
139 let (x, y) = res!(self.public_key_xy());
140 Ok(mapdat!{
141 "kty" => "EC",
142 "crv" => "P-256",
143 "x" => base64url_encode(&x),
144 "y" => base64url_encode(&y),
145 })
146 }
147
148 /// The required members of an EC key are `crv`, `kty`, `x` and `y`. They go
149 /// to [`jwk_thumbprint_sha256_of`] deliberately out of lexicographic order,
150 /// so that the canonicalisation -- not the caller -- is what puts them in
151 /// the order RFC 7638 §3 mandates.
152 pub fn jwk_thumbprint_sha256(&self) -> Outcome<[u8; 32]> {
153 let (x, y) = res!(self.public_key_xy());
154 let x_b64 = base64url_encode(&x);
155 let y_b64 = base64url_encode(&y);
156 jwk_thumbprint_sha256_of(&[
157 ("kty", "EC"),
158 ("x", &x_b64),
159 ("y", &y_b64),
160 ("crv", "P-256"),
161 ])
162 }
163
164 /// The flattened JSON serialisation of RFC 7515 §7.2.2.
165 ///
166 /// `protected_header` must be a `Dat::Map` holding `alg`, `url`, `nonce` and
167 /// either `jwk` or `kid`; it is serialised to compact JSON, base64url-encoded
168 /// and signed as the `"<b64 header>.<b64 payload>"` input of RFC 7515 §5.1.
169 /// An empty `payload_bytes` is the "POST-as-GET" of RFC 8555 §6.3. The map
170 /// returned holds `protected`, `payload` and `signature`, each base64url, and
171 /// goes into the request body through `.json()`.
172 pub fn sign_flattened(
173 &self,
174 protected_header: &Dat,
175 payload_bytes: &[u8],
176 )
177 -> Outcome<Dat>
178 {
179 let header_json = res!(protected_header.json());
180 let header_b64 = base64url_encode(header_json.as_bytes());
181 let payload_b64 = base64url_encode(payload_bytes);
182 let signing_input = fmt!("{}.{}", header_b64, payload_b64);
183 let sig = match self.key_pair.sign(&self.rng, signing_input.as_bytes()) {
184 Ok(s) => s,
185 Err(_) => return Err(err!(
186 "ring::signature::EcdsaKeyPair::sign failed to produce an \
187 ES256 signature for the JWS signing input.";
188 Unknown)),
189 };
190 let sig_b64 = base64url_encode(sig.as_ref());
191 Ok(mapdat!{
192 "protected" => header_b64,
193 "payload" => payload_b64,
194 "signature" => sig_b64,
195 })
196 }
197
198 /// `ring` exposes the public key as an uncompressed SEC1 point
199 /// `0x04 || x || y`, 65 bytes for P-256. The shape is checked here so that
200 /// callers see two plain 32-byte arrays.
201 fn public_key_xy(&self) -> Outcome<([u8; 32], [u8; 32])> {
202 let pk = self.key_pair.public_key().as_ref();
203 if pk.len() != 65 || pk[0] != 0x04 {
204 return Err(err!(
205 "ring::signature::EcdsaKeyPair::public_key returned a \
206 representation that is not a 65-byte uncompressed SEC1 \
207 point (got {} bytes, leading byte {:#04x}).",
208 pk.len(), pk[0];
209 Invalid, Size, Unknown));
210 }
211 let mut x = [0u8; 32];
212 let mut y = [0u8; 32];
213 x.copy_from_slice(&pk[1..33]);
214 y.copy_from_slice(&pk[33..65]);
215 Ok((x, y))
216 }
217}
218
219
220// ┌───────────────────────────────────────────────────────────────────────────┐
221// │ HELPERS │
222// └───────────────────────────────────────────────────────────────────────────┘
223
224/// The RFC 7638 §3 canonical form is defined as an exact byte sequence, not merely a
225/// structurally equivalent JSON document: the **required** members only (for
226/// an EC key `crv`, `kty`, `x` and `y`; for an RSA key `e`, `kty` and `n`),
227/// lexicographically ordered by member name, with no whitespace and no line
228/// breaks. A CA recomputes this string from the JWK we send it and hashes the
229/// result, so a single stray space or a reordered member silently invalidates
230/// every challenge we ever answer.
231///
232/// The members are sorted here rather than trusted from the caller, and the
233/// string is assembled by hand rather than handed to a general-purpose JSON
234/// serialiser, because no serialiser guarantees the byte-exactness the RFC
235/// requires. Members are taken as `(name, value)` pairs because every required
236/// member of every key type RFC 7638 covers has a string value.
237pub fn jwk_canonical_string(members: &[(&str, &str)]) -> Outcome<String> {
238 if members.is_empty() {
239 return Err(err!(
240 "An RFC 7638 canonical JWK needs at least one required member, \
241 none were supplied.";
242 Invalid, Input, Missing));
243 }
244 let mut sorted = members.to_vec();
245 // Lexicographic order by member name. Rust's `str` ordering compares by
246 // byte, which for UTF-8 is equivalent to ordering by code point, and the
247 // member names RFC 7638 defines are all ASCII in any case.
248 sorted.sort_by(|a, b| a.0.cmp(b.0));
249 for pair in sorted.windows(2) {
250 if pair[0].0 == pair[1].0 {
251 return Err(err!(
252 "Duplicate JWK member name {:?}; the canonical form would \
253 silently drop one of them.", pair[0].0;
254 Invalid, Input, Conflict));
255 }
256 }
257 let mut out = String::from("{");
258 for (i, (name, value)) in sorted.iter().enumerate() {
259 if i > 0 {
260 out.push(',');
261 }
262 out.push('"');
263 out.push_str(&json_escape(name));
264 out.push_str("\":\"");
265 out.push_str(&json_escape(value));
266 out.push('"');
267 }
268 out.push('}');
269 Ok(out)
270}
271
272/// RFC 7638 §3; the members are canonicalised by [`jwk_canonical_string`] first.
273pub fn jwk_thumbprint_sha256_of(members: &[(&str, &str)]) -> Outcome<[u8; 32]> {
274 let canonical = res!(jwk_canonical_string(members));
275 Ok(sha256(canonical.as_bytes()))
276}
277
278/// RFC 8259 §7. Base64url payloads and the short ASCII tokens RFC 7638 uses as
279/// member names never need escaping, so in practice this is the identity
280/// function; it exists so the canonical form stays valid JSON whatever it is
281/// handed.
282fn json_escape(s: &str) -> String {
283 let mut out = String::with_capacity(s.len());
284 for c in s.chars() {
285 match c {
286 '"' => out.push_str("\\\""),
287 '\\' => out.push_str("\\\\"),
288 '\n' => out.push_str("\\n"),
289 '\r' => out.push_str("\\r"),
290 '\t' => out.push_str("\\t"),
291 c if (c as u32) < 0x20 => out.push_str(&fmt!("\\u{:04x}", c as u32)),
292 c => out.push(c),
293 }
294 }
295 out
296}
297
298/// URL-safe base64 without padding, as required by RFC 7515 §2.
299pub fn base64url_encode(bytes: &[u8]) -> String {
300 base64::encode_config(bytes, base64::URL_SAFE_NO_PAD)
301}
302
303/// URL-safe base64 decoder that tolerates missing padding.
304pub fn base64url_decode(s: &str) -> Outcome<Vec<u8>> {
305 match base64::decode_config(s, base64::URL_SAFE_NO_PAD) {
306 Ok(v) => Ok(v),
307 Err(e) => Err(err!(e,
308 "Failed to decode {:?} as URL-safe base64 without padding.", s;
309 Invalid, Input, Decode)),
310 }
311}
312
313fn sha256(data: &[u8]) -> [u8; 32] {
314 let mut ctx = Context::new(&SHA256);
315 ctx.update(data);
316 let digest = ctx.finish();
317 let mut out = [0u8; 32];
318 out.copy_from_slice(digest.as_ref());
319 out
320}
321
322
323// ┌───────────────────────────────────────────────────────────────────────────┐
324// │ TEST VECTORS │
325// └───────────────────────────────────────────────────────────────────────────┘
326
327/// A fixed P-256 account key in PKCS#8, used to pin the EC thumbprint path
328/// against an externally-derived expected value. Shared with the
329/// [`crate::acme::rfc8555`] tests so the key authorisation and dns-01 vectors
330/// chain off the very same key.
331///
332/// Generated once, outside this crate, with:
333///
334/// ```text
335/// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
336/// -outform DER -out k.der
337/// openssl pkcs8 -topk8 -nocrypt -inform DER -in k.der -outform DER
338/// ```
339///
340/// This is a throwaway test key and guards nothing.
341#[cfg(test)]
342pub(crate) const TEST_P256_PKCS8: [u8; 138] = [
343 0x30, 0x81, 0x87, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86,
344 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d,
345 0x03, 0x01, 0x07, 0x04, 0x6d, 0x30, 0x6b, 0x02, 0x01, 0x01, 0x04, 0x20,
346 0x36, 0x69, 0x61, 0xe3, 0x3c, 0xdb, 0xf0, 0x14, 0x7f, 0xc3, 0xc0, 0x0c,
347 0x8b, 0xea, 0xfd, 0xa5, 0xa4, 0x6d, 0x21, 0xfa, 0xed, 0xa2, 0x06, 0x98,
348 0x8a, 0x36, 0xc5, 0xc2, 0xa8, 0x87, 0xc3, 0x39, 0xa1, 0x44, 0x03, 0x42,
349 0x00, 0x04, 0x70, 0xc0, 0x18, 0x21, 0x82, 0x6e, 0xec, 0x0d, 0x9a, 0x35,
350 0x34, 0xeb, 0xba, 0xb4, 0x96, 0x04, 0x53, 0x30, 0xaf, 0xcb, 0xb2, 0x55,
351 0x16, 0x06, 0xbe, 0xba, 0xe0, 0xb1, 0x4c, 0xfc, 0x23, 0xa4, 0x2a, 0xda,
352 0xb2, 0xda, 0x17, 0x2f, 0x8c, 0x8c, 0xbf, 0x16, 0x87, 0xce, 0xe3, 0xb3,
353 0x1f, 0x59, 0xe1, 0xcb, 0x82, 0x33, 0x7b, 0x55, 0xdc, 0x70, 0xd0, 0x1a,
354 0x76, 0x31, 0x14, 0x32, 0x1b, 0xc6,
355];
356
357/// The RFC 7638 §3 thumbprint of [`TEST_P256_PKCS8`], base64url-encoded.
358///
359/// Derived **independently of this crate**, from the DER above. The public
360/// point is the trailing 65 bytes of the SubjectPublicKeyInfo
361/// (`04 || X || Y`), giving:
362///
363/// ```text
364/// x = cMAYIYJu7A2aNTTrurSWBFMwr8uyVRYGvrrgsUz8I6Q
365/// y = Ktqy2hcvjIy_FofO47MfWeHLgjN7Vdxw0Bp2MRQyG8Y
366/// ```
367///
368/// and therefore the RFC 7638 canonical string
369///
370/// ```text
371/// {"crv":"P-256","kty":"EC","x":"cMAYIYJu7A2aNTTrurSWBFMwr8uyVRYGvrrgsUz8I6Q","y":"Ktqy2hcvjIy_FofO47MfWeHLgjN7Vdxw0Bp2MRQyG8Y"}
372/// ```
373///
374/// whose SHA-256, base64url-encoded without padding, is the value below.
375/// Re-derive with `openssl` and `python3`:
376///
377/// ```text
378/// openssl pkey -inform DER -in k8.der -pubout -outform DER -out pub.der
379/// python3 -c '
380/// import hashlib, base64
381/// p = open("pub.der","rb").read()[-65:]
382/// b = lambda v: base64.urlsafe_b64encode(v).rstrip(b"=").decode()
383/// c = chr(123)+chr(34)+"crv"+chr(34)+":"+chr(34)+"P-256"+chr(34)+","+chr(34)+"kty"+chr(34)+":"+chr(34)+"EC"+chr(34)+","+chr(34)+"x"+chr(34)+":"+chr(34)+b(p[1:33])+chr(34)+","+chr(34)+"y"+chr(34)+":"+chr(34)+b(p[33:65])+chr(34)+chr(125)
384/// print(b(hashlib.sha256(c.encode()).digest()))'
385/// ```
386#[cfg(test)]
387pub(crate) const TEST_P256_THUMBPRINT_B64: &str =
388 "rIV82OX7WtoQ9t9CvXXciOOey0zuRuaonj8p-bQghoA";
389
390
391// ┌───────────────────────────────────────────────────────────────────────────┐
392// │ TESTS │
393// └───────────────────────────────────────────────────────────────────────────┘
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 use ring::signature::{
400 UnparsedPublicKey,
401 ECDSA_P256_SHA256_FIXED,
402 };
403
404 // ---- RFC 7638 external oracles ---------------------------------------
405
406 // The RSA key from the RFC 7638 §3.1 worked example. `e`, `kty` and `n`
407 // are the required members of an RSA JWK; everything else in the RFC's
408 // example JWK (`alg`, `kid`, `use`) is excluded from the canonical form by
409 // §3, and this vector is precisely what proves we exclude them.
410 const RFC7638_RSA_N: &str = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4\
411 cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5Js\
412 GY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZg\
413 nYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lF\
414 d2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw";
415
416 // The canonical string RFC 7638 §3.1 prints for that key, byte for byte.
417 const RFC7638_RSA_CANONICAL: &str = "{\"e\":\"AQAB\",\"kty\":\"RSA\",\"n\":\"0vx7\
418 agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1\
419 L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6C\
420 f0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajr\
421 n1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw\
422 0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw\"}";
423
424 // The thumbprint RFC 7638 §3.1 publishes for that key.
425 const RFC7638_RSA_THUMBPRINT_B64: &str = "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs";
426
427 /// **External oracle, RFC 7638 §3.1.** The canonical string we build for
428 /// the RFC's own RSA example must equal the one the RFC prints, byte for
429 /// byte. This is the test that a self-consistently wrong implementation
430 /// cannot pass: reordering the members, inserting whitespace, or padding
431 /// the base64 all change these bytes, while leaving a determinism test
432 /// perfectly happy.
433 ///
434 /// Note the members are handed over deliberately unsorted, so the
435 /// canonicaliser -- not this test -- is what establishes the order.
436 #[test]
437 fn test_rfc7638_rsa_canonical_string_matches_the_rfc() -> Outcome<()> {
438 let canonical = res!(jwk_canonical_string(&[
439 ("n", RFC7638_RSA_N),
440 ("kty", "RSA"),
441 ("e", "AQAB"),
442 ]));
443 if canonical != RFC7638_RSA_CANONICAL {
444 return Err(err!(
445 "RFC 7638 §3.1 canonical string mismatch.\n ours: {}\n rfc: {}",
446 canonical, RFC7638_RSA_CANONICAL;
447 Test, Mismatch));
448 }
449 Ok(())
450 }
451
452 /// **External oracle, RFC 7638 §3.1.** The SHA-256 thumbprint of the
453 /// RFC's RSA example must equal the value the RFC publishes.
454 #[test]
455 fn test_rfc7638_rsa_thumbprint_matches_the_rfc() -> Outcome<()> {
456 let tp = res!(jwk_thumbprint_sha256_of(&[
457 ("n", RFC7638_RSA_N),
458 ("kty", "RSA"),
459 ("e", "AQAB"),
460 ]));
461 let got = base64url_encode(&tp);
462 if got != RFC7638_RSA_THUMBPRINT_B64 {
463 return Err(err!(
464 "RFC 7638 §3.1 thumbprint mismatch: got {:?}, RFC publishes {:?}.",
465 got, RFC7638_RSA_THUMBPRINT_B64;
466 Test, Mismatch));
467 }
468 Ok(())
469 }
470
471 /// The canonicaliser must sort by member name regardless of the order it
472 /// is given them in, and must emit no whitespace whatsoever.
473 #[test]
474 fn test_jwk_canonical_string_sorts_and_omits_whitespace() -> Outcome<()> {
475 let canonical = res!(jwk_canonical_string(&[
476 ("y", "YY"),
477 ("kty", "EC"),
478 ("crv", "P-256"),
479 ("x", "XX"),
480 ]));
481 let expected = "{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"XX\",\"y\":\"YY\"}";
482 if canonical != expected {
483 return Err(err!(
484 "Canonical string was {:?}, expected {:?}.", canonical, expected;
485 Test, Mismatch));
486 }
487 if canonical.contains(' ') || canonical.contains('\n') || canonical.contains('\t') {
488 return Err(err!(
489 "Canonical string contains whitespace: {:?}.", canonical;
490 Test, Invalid));
491 }
492 Ok(())
493 }
494
495 /// A duplicate member name would silently drop data from the hash input,
496 /// so it must be refused rather than canonicalised.
497 #[test]
498 fn test_jwk_canonical_string_rejects_duplicate_members() -> Outcome<()> {
499 match jwk_canonical_string(&[("kty", "EC"), ("kty", "RSA")]) {
500 Ok(s) => Err(err!(
501 "Duplicate member name was accepted, producing {:?}.", s;
502 Test, Mismatch)),
503 Err(_) => Ok(()),
504 }
505 }
506
507 /// **External oracle, EC path.** The thumbprint of the fixed P-256 key in
508 /// [`TEST_P256_PKCS8`], computed through the real production path
509 /// (`from_pkcs8` -> `jwk_thumbprint_sha256`), must equal the value derived
510 /// independently with `openssl` and `python3`. See the doc comment on
511 /// [`TEST_P256_THUMBPRINT_B64`] for the derivation.
512 #[test]
513 fn test_ec_thumbprint_matches_external_oracle() -> Outcome<()> {
514 let signer = res!(JwsSigner::from_pkcs8(&TEST_P256_PKCS8));
515 let tp = res!(signer.jwk_thumbprint_sha256());
516 let got = base64url_encode(&tp);
517 if got != TEST_P256_THUMBPRINT_B64 {
518 return Err(err!(
519 "EC thumbprint for the pinned P-256 key was {:?}, but the \
520 externally-derived value is {:?}.", got, TEST_P256_THUMBPRINT_B64;
521 Test, Mismatch));
522 }
523 Ok(())
524 }
525
526 /// The JWK the signer publishes and the JWK the thumbprint is taken over
527 /// must describe the same key: a CA recomputes the thumbprint from the
528 /// `jwk` header we send, so any drift between the two breaks every
529 /// challenge. Pins both against the same external oracle.
530 #[test]
531 fn test_public_jwk_agrees_with_thumbprint_input() -> Outcome<()> {
532 let signer = res!(JwsSigner::from_pkcs8(&TEST_P256_PKCS8));
533 let jwk = res!(signer.public_jwk());
534 let kty = res!(map_get_str(&jwk, "kty"));
535 let crv = res!(map_get_str(&jwk, "crv"));
536 let x = res!(map_get_str(&jwk, "x"));
537 let y = res!(map_get_str(&jwk, "y"));
538
539 // Rebuild the thumbprint from the *published* JWK members alone.
540 let tp = res!(jwk_thumbprint_sha256_of(&[
541 ("kty", &kty),
542 ("crv", &crv),
543 ("x", &x),
544 ("y", &y),
545 ]));
546 let got = base64url_encode(&tp);
547 if got != TEST_P256_THUMBPRINT_B64 {
548 return Err(err!(
549 "Thumbprint taken over the published JWK is {:?}, but the \
550 externally-derived value is {:?}.", got, TEST_P256_THUMBPRINT_B64;
551 Test, Mismatch));
552 }
553 Ok(())
554 }
555
556 /// Pull one string-valued entry out of a `Dat::Map` by key, returning an
557 /// `Outcome` error on absence or type mismatch. Used by the tests below.
558 fn map_get_str(dat: &Dat, key: &str) -> Outcome<String> {
559 match dat {
560 Dat::Map(m) => match m.get(&dat!(key.to_string())) {
561 Some(Dat::Str(s)) => Ok(s.clone()),
562 Some(other) => Err(err!(
563 "Expected Dat::Str at key {:?}, found {:?}.", key, other;
564 Invalid, Mismatch)),
565 None => Err(err!(
566 "Missing key {:?} in Dat::Map.", key;
567 Missing, Input)),
568 },
569 _ => Err(err!(
570 "Expected Dat::Map, found {:?}.", dat;
571 Invalid, Mismatch)),
572 }
573 }
574
575 /// Generate a fresh signer, produce a flattened JWS, then verify the
576 /// signature against the signer's own public key via `ring`. End-to-end
577 /// correctness of sign + serialise.
578 #[test]
579 fn test_sign_verify_round_trip() -> Outcome<()> {
580 let signer = res!(JwsSigner::new_es256());
581 let header = mapdat!{
582 "alg" => "ES256",
583 "nonce" => "deadbeef",
584 "url" => "https://example.test/acme/new-order",
585 };
586 let payload = b"{\"identifiers\":[{\"type\":\"dns\",\"value\":\"example.test\"}]}";
587
588 let jws = res!(signer.sign_flattened(&header, payload));
589
590 // Recover the signing input the way a verifier would.
591 let prot_b64 = res!(map_get_str(&jws, "protected"));
592 let load_b64 = res!(map_get_str(&jws, "payload"));
593 let sig_b64 = res!(map_get_str(&jws, "signature"));
594 let signing_input = fmt!("{}.{}", prot_b64, load_b64);
595 let sig_bytes = res!(base64url_decode(&sig_b64));
596
597 // Verify with ring directly, using the raw public key bytes.
598 let pk_bytes = signer.key_pair.public_key().as_ref();
599 let verifier = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, pk_bytes);
600 match verifier.verify(signing_input.as_bytes(), &sig_bytes) {
601 Ok(()) => (),
602 Err(_) => return Err(err!(
603 "ring rejected a signature that JwsSigner::sign_flattened had \
604 just produced for the same public key.";
605 Test, Unknown)),
606 }
607
608 // Payload must round-trip through base64url.
609 let decoded_payload = res!(base64url_decode(&load_b64));
610 if decoded_payload != payload {
611 return Err(err!(
612 "JWS payload did not round-trip: expected {} bytes, got {}.",
613 payload.len(), decoded_payload.len();
614 Test, Mismatch));
615 }
616 Ok(())
617 }
618
619 /// Empty payload -- the RFC 8555 §6.3 "POST-as-GET" case -- must produce a
620 /// valid JWS whose payload field is the empty base64url string.
621 #[test]
622 fn test_sign_post_as_get() -> Outcome<()> {
623 let signer = res!(JwsSigner::new_es256());
624 let header = mapdat!{
625 "alg" => "ES256",
626 "nonce" => "nonceval",
627 "url" => "https://example.test/acme/order/1",
628 };
629 let jws = res!(signer.sign_flattened(&header, b""));
630 let load_b64 = res!(map_get_str(&jws, "payload"));
631 if !load_b64.is_empty() {
632 return Err(err!(
633 "POST-as-GET payload must be base64url-encoded empty bytes \
634 (the empty string), got {:?}.", load_b64;
635 Test, Mismatch));
636 }
637 Ok(())
638 }
639
640 /// Generate, serialise to PKCS#8, reload, and verify that a signature
641 /// produced by the reloaded signer verifies against the original signer's
642 /// public key. Confirms the PKCS#8 round-trip preserves identity.
643 #[test]
644 fn test_pkcs8_round_trip() -> Outcome<()> {
645 let first = res!(JwsSigner::new_es256());
646 let pkcs8 = first.pkcs8_bytes().to_vec();
647 let second = res!(JwsSigner::from_pkcs8(&pkcs8));
648
649 // Both signers should expose the same public key bytes.
650 let pk1 = first.key_pair.public_key().as_ref().to_vec();
651 let pk2 = second.key_pair.public_key().as_ref().to_vec();
652 if pk1 != pk2 {
653 return Err(err!(
654 "PKCS#8 round-trip produced a signer with a different public \
655 key (orig {} bytes, reload {} bytes).", pk1.len(), pk2.len();
656 Test, Mismatch));
657 }
658
659 // And the retained pkcs8 bytes should be equal to what we loaded.
660 if second.pkcs8_bytes() != pkcs8.as_slice() {
661 return Err(err!(
662 "PKCS#8 round-trip: retained bytes in reloaded signer \
663 differ from the input.";
664 Test, Mismatch));
665 }
666 Ok(())
667 }
668
669 /// The JWK thumbprint must be deterministic and exactly 32 bytes wide.
670 /// Two calls on the same signer must yield identical output.
671 #[test]
672 fn test_jwk_thumbprint_deterministic() -> Outcome<()> {
673 let signer = res!(JwsSigner::new_es256());
674 let t1 = res!(signer.jwk_thumbprint_sha256());
675 let t2 = res!(signer.jwk_thumbprint_sha256());
676 if t1 != t2 {
677 return Err(err!(
678 "JwsSigner::jwk_thumbprint_sha256 is not deterministic on \
679 the same signer.";
680 Test, Mismatch));
681 }
682 Ok(())
683 }
684
685 /// `public_jwk` must return a `Dat::Map` with the four required ES256 JWK
686 /// members, each of the expected type and shape.
687 #[test]
688 fn test_public_jwk_shape() -> Outcome<()> {
689 let signer = res!(JwsSigner::new_es256());
690 let jwk = res!(signer.public_jwk());
691 let kty = res!(map_get_str(&jwk, "kty"));
692 let crv = res!(map_get_str(&jwk, "crv"));
693 let x = res!(map_get_str(&jwk, "x"));
694 let y = res!(map_get_str(&jwk, "y"));
695 if kty != "EC" {
696 return Err(err!("Expected kty == \"EC\", got {:?}.", kty;
697 Test, Mismatch));
698 }
699 if crv != "P-256" {
700 return Err(err!("Expected crv == \"P-256\", got {:?}.", crv;
701 Test, Mismatch));
702 }
703 // Base64url of a 32-byte coordinate is 43 characters when unpadded.
704 if x.len() != 43 || y.len() != 43 {
705 return Err(err!(
706 "Expected base64url-encoded 32-byte coordinates (43 chars), \
707 got x={} y={}.", x.len(), y.len();
708 Test, Mismatch));
709 }
710 let x_bytes = res!(base64url_decode(&x));
711 let y_bytes = res!(base64url_decode(&y));
712 if x_bytes.len() != 32 || y_bytes.len() != 32 {
713 return Err(err!(
714 "Decoded JWK coordinates are not 32 bytes each: x={} y={}.",
715 x_bytes.len(), y_bytes.len();
716 Test, Size));
717 }
718 Ok(())
719 }
720}