Skip to main content

feather_reader/oauth/
keys.rs

1//! The OAuth confidential-client signing key, and the documents derived from it.
2//!
3//! A production atproto OAuth client is a *confidential* client: it authenticates
4//! to the PDS with `private_key_jwt`, signing a client assertion with a long-lived
5//! ES256 key. That key must
6//!
7//! * survive restarts (it anchors `client_id`, so regenerating it invalidates
8//!   in-flight authorizations),
9//! * be published in public form at `jwks_uri` so the PDS can verify assertions,
10//! * and not sit on disk in the clear **whenever a codec key is configured**.
11//!
12//! So it is persisted as a JWK, AEAD-encrypted with [`super::crypto`]. The JWK
13//! *format* is deliberately the same one the Node sidecar writes, so a rollback
14//! to the sidecar can still read a key this module created, and vice versa.
15//!
16//! That third requirement is conditional, and the condition is load-bearing:
17//! [`super::crypto::Codec::Null`] is a pass-through, so a deployment with no key
18//! configured writes the private JWK as plaintext. The sidecar refuses to boot
19//! in production without a key; the equivalent guard for this path belongs in
20//! config validation at cutover, and until then `Codec::Null` is a dev-only
21//! arrangement rather than an enforced one.
22//!
23//! The thumbprint is RFC 7638 and is computed over the PUBLIC members only
24//! (`crv`, `kty`, `x`, `y`) in lexicographic order — never over the raw JWK
25//! text, and never including `d`.
26
27use anyhow::{anyhow, bail, Context as _, Result};
28use base64::engine::general_purpose::URL_SAFE_NO_PAD;
29use base64::Engine;
30use p256::SecretKey;
31use ring::digest::{digest, SHA256};
32use serde_json::{json, Value};
33use std::fs;
34use std::io::Write as _;
35use std::path::Path;
36
37use super::crypto::{Aead, Codec};
38
39/// The JOSE algorithm this client signs assertions with. atproto's OAuth profile
40/// requires ES256 for `private_key_jwt`.
41const ALG: &str = "ES256";
42
43/// Extract a required string member from a JWK object.
44fn member<'a>(jwk: &'a Value, name: &str) -> Result<&'a str> {
45    jwk.get(name)
46        .and_then(Value::as_str)
47        .ok_or_else(|| anyhow!("JWK is missing the required `{name}` member"))
48}
49
50/// RFC 7638 thumbprint of an EC JWK.
51///
52/// The construction is the whole point: take ONLY the required members for the
53/// key type (`crv`, `kty`, `x`, `y` for EC), emit them as compact JSON in
54/// lexicographic order, SHA-256 that, base64url-encode without padding.
55///
56/// Hashing the raw JWK text instead would make the value depend on whitespace,
57/// member order and any extra members, and would fold in the private `d` — it
58/// would never match what the PDS computes.
59fn thumbprint_of_members(jwk: &Value) -> Result<String> {
60    let (crv, kty, x, y) = (
61        member(jwk, "crv")?,
62        member(jwk, "kty")?,
63        member(jwk, "x")?,
64        member(jwk, "y")?,
65    );
66    if kty != "EC" {
67        bail!("unsupported JWK key type {kty:?}; expected EC");
68    }
69    if crv != "P-256" {
70        bail!("unsupported JWK curve {crv:?}; only P-256 is supported");
71    }
72    // `x`/`y` are unpadded base64url in a well-formed EC JWK. The check is on
73    // the ALPHABET, which is all that is needed for the property that matters
74    // here: no member can require JSON escaping, so the canonical form is
75    // unambiguous. It is not a validity check — length and decodability are not
76    // verified, and do not need to be, since the caller either supplies our own
77    // key or is about to hand the JWK to `p256` anyway.
78    for (name, value) in [("x", x), ("y", y)] {
79        if value.is_empty()
80            || !value
81                .bytes()
82                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
83        {
84            bail!("JWK member `{name}` is not unpadded base64url");
85        }
86    }
87    // Serialized, not interpolated, so the escaping is the serializer's job.
88    // The literal below is also WRITTEN in lexicographic order, so the output is
89    // RFC-canonical whether `serde_json::Map` is a `BTreeMap` (the default) or
90    // an insertion-ordered map (were `preserve_order` ever pulled in by feature
91    // unification elsewhere in the graph).
92    let canonical = serde_json::to_string(&json!({"crv": crv, "kty": kty, "x": x, "y": y}))
93        .context("serializing the canonical JWK")?;
94    Ok(URL_SAFE_NO_PAD.encode(digest(&SHA256, canonical.as_bytes()).as_ref()))
95}
96
97/// The confidential client's long-lived ES256 signing key.
98pub struct SigningKey {
99    secret: SecretKey,
100    kid: String,
101}
102
103impl SigningKey {
104    /// Generate a fresh P-256 key.
105    ///
106    /// Draws from the OS CSPRNG via `getrandom`, the same source as the rest of
107    /// the app, rather than pulling in a second RNG stack. A uniformly random
108    /// 32-byte value is a valid P-256 scalar with overwhelming probability; the
109    /// retry covers the ~2^-32 case where it lands outside the curve order.
110    pub fn generate(kid: &str) -> Self {
111        for _ in 0..8 {
112            let mut bytes = [0u8; 32];
113            getrandom::fill(&mut bytes)
114                .expect("OS CSPRNG unavailable; refusing to generate a signing key");
115            if let Ok(secret) = SecretKey::from_slice(&bytes) {
116                return Self {
117                    secret,
118                    kid: kid.to_string(),
119                };
120            }
121        }
122        unreachable!("8 consecutive invalid P-256 scalars is not physically plausible")
123    }
124
125    /// Parse a JWK, tolerating the extra members (`kid`, `alg`, `key_ops`,
126    /// `use`) that both this module and the sidecar attach.
127    pub fn from_jwk_json(jwk_json: &str, kid: &str) -> Result<Self> {
128        let v: Value = serde_json::from_str(jwk_json).context("key file is not valid JSON")?;
129        if v.get("d").is_none() {
130            bail!("JWK has no `d` member; a signing key must be a private key");
131        }
132        // Hand p256 only the members it defines, so an unexpected extra member
133        // can never make a valid key fail to parse.
134        let minimal = json!({
135            "kty": member(&v, "kty")?,
136            "crv": member(&v, "crv")?,
137            "x": member(&v, "x")?,
138            "y": member(&v, "y")?,
139            "d": member(&v, "d")?,
140        });
141        let secret = SecretKey::from_jwk_str(&minimal.to_string())
142            .map_err(|err| anyhow!("not a valid P-256 private JWK: {err}"))?;
143        // Prefer the kid already in the file: it is what a PDS may have cached
144        // against the published JWKS.
145        let kid = v
146            .get("kid")
147            .and_then(Value::as_str)
148            .unwrap_or(kid)
149            .to_string();
150        Ok(Self { secret, kid })
151    }
152
153    /// The PRIVATE JWK, for persistence. Carries `kid`/`alg`/`key_ops` so a
154    /// rollback to the sidecar finds the key under the same identifier.
155    pub fn to_jwk_json(&self) -> Result<String> {
156        let mut v: Value =
157            serde_json::to_value(self.secret.to_jwk()).context("serializing the private JWK")?;
158        let obj = v
159            .as_object_mut()
160            .ok_or_else(|| anyhow!("JWK did not serialize to an object"))?;
161        obj.insert("kid".into(), json!(self.kid));
162        obj.insert("alg".into(), json!(ALG));
163        // `key_ops`, not `use`, on the PRIVATE JWK: `jose` warns that a private
164        // JWK carrying `use` will be rejected in a future release, which would
165        // break the rollback path this shared format exists to preserve.
166        //
167        // (The sidecar writes NEITHER — `JoseKey.generate(...).privateJwk` is
168        // just `{kty, kid, crv, x, y, d}`. An earlier version of this comment
169        // claimed `key_ops` matched the sidecar; it does not. The change stands
170        // on the deprecation alone, and jwk-jose loads the result warning-free.)
171        obj.insert("key_ops".into(), json!(["sign"]));
172        serde_json::to_string(&v).context("rendering the private JWK")
173    }
174
175    /// The PUBLIC JWK — what `/jwks.json` serves. Never contains `d`.
176    pub fn public_jwk(&self) -> Result<Value> {
177        let mut v: Value = serde_json::to_value(self.secret.public_key().to_jwk())
178            .context("serializing the public JWK")?;
179        let obj = v
180            .as_object_mut()
181            .ok_or_else(|| anyhow!("JWK did not serialize to an object"))?;
182        // Defensive: the public JWK must never carry the private scalar, whatever
183        // the upstream serializer does.
184        obj.remove("d");
185        obj.insert("kid".into(), json!(self.kid));
186        obj.insert("alg".into(), json!(ALG));
187        obj.insert("use".into(), json!("sig"));
188        Ok(v)
189    }
190
191    /// The document served at `jwks_uri`, so the PDS can verify our assertions.
192    pub fn jwks_document(&self) -> Result<Value> {
193        Ok(json!({ "keys": [self.public_jwk()?] }))
194    }
195
196    /// RFC 7638 thumbprint of this key's public half.
197    pub fn thumbprint(&self) -> Result<String> {
198        thumbprint_of_members(&self.public_jwk()?)
199    }
200
201    /// RFC 7638 thumbprint of an arbitrary EC JWK document.
202    pub fn public_thumbprint_of(jwk_json: &str) -> Result<String> {
203        let v: Value = serde_json::from_str(jwk_json).context("not valid JSON")?;
204        thumbprint_of_members(&v)
205    }
206
207    pub fn kid(&self) -> &str {
208        &self.kid
209    }
210
211    /// The raw secret, for [`super::jwt`] to sign with. Deliberately
212    /// `pub(crate)`: nothing outside this crate should be able to reach the
213    /// private scalar, and nothing outside [`super::jwt`] needs to.
214    pub(crate) fn secret(&self) -> &SecretKey {
215        &self.secret
216    }
217}
218
219/// A staged temp file that deletes itself on drop unless told not to.
220///
221/// Without this, any failure between creating the temp and linking it into
222/// place leaves the file behind until some later write happens to clear it.
223struct Staged {
224    path: std::path::PathBuf,
225    keep: bool,
226}
227
228impl Staged {
229    /// Write `contents` to a **uniquely named** sibling of `near`, fsynced.
230    ///
231    /// The name carries the pid and CSPRNG bytes deliberately. A shared,
232    /// predictable temp path lets two processes interleave `remove` and
233    /// `create` such that one renames the other's empty file into place —
234    /// producing exactly the zero-length key file this staging exists to
235    /// prevent. A unique name makes the collision impossible instead of
236    /// unlikely, and removes any need to clear a stale temp first (which is
237    /// what opened the window).
238    fn write(near: &Path, contents: &str) -> Result<Self> {
239        let mut suffix = [0u8; 8];
240        getrandom::fill(&mut suffix).expect("OS CSPRNG unavailable; refusing to stage a key");
241        let mut name = near.as_os_str().to_os_string();
242        name.push(format!(
243            ".{}.{}.tmp",
244            std::process::id(),
245            suffix
246                .iter()
247                .map(|b| format!("{b:02x}"))
248                .collect::<String>()
249        ));
250        let path = std::path::PathBuf::from(name);
251
252        let mut opts = fs::OpenOptions::new();
253        opts.write(true).create_new(true);
254        #[cfg(unix)]
255        {
256            use std::os::unix::fs::OpenOptionsExt as _;
257            opts.mode(0o600);
258        }
259        let mut file = opts
260            .open(&path)
261            .with_context(|| format!("staging a key write at {}", path.display()))?;
262        let staged = Self { path, keep: false };
263
264        file.write_all(contents.as_bytes())
265            .context("writing the staged signing-key file")?;
266        // Durable BEFORE it is linked into place, so the final link can never
267        // expose a file whose contents have not reached disk.
268        file.sync_all().context("flushing the staged key file")?;
269        Ok(staged)
270    }
271
272    fn path(&self) -> &Path {
273        &self.path
274    }
275
276    /// The staged file has become the real one; stop tracking it.
277    fn keep(mut self) {
278        self.keep = true;
279    }
280}
281
282impl Drop for Staged {
283    fn drop(&mut self) {
284        if !self.keep {
285            let _ = fs::remove_file(&self.path);
286        }
287    }
288}
289
290/// Create the key file **atomically**, refusing to clobber an existing one.
291///
292/// Stage, then `hard_link` into place. `hard_link` is the reason this is not a
293/// `rename`: it fails with `EEXIST` if the destination exists, which preserves
294/// the no-clobber guard against a racing process that already generated and
295/// published a key — a guard a `rename` would silently discard.
296///
297/// A plain `create_new` + `write` is NOT equivalent: it is atomic with respect
298/// to *existence* but not to *content*, so a crash between the open and the
299/// write leaves a zero-length file, and an unreadable key file is deliberately
300/// a hard error (see [`load_or_create`]).
301/// Returns `Ok(false)` when the file already exists — someone else won the race
302/// and their key is the one to use.
303fn write_new_owner_only(path: &Path, contents: &str) -> Result<bool> {
304    let staged = Staged::write(path, contents)?;
305    if let Err(err) = fs::hard_link(staged.path(), path) {
306        // EEXIST is not a failure, it is the outcome of a race we can lose
307        // safely: the file now at `path` is exactly the thing we wanted. Two
308        // replicas starting together both take the NotFound branch, both
309        // generate a key, and both link; treating the loser's EEXIST as fatal
310        // meant every replica but one refused to boot, and a scheduler
311        // restarting them together could flap indefinitely.
312        //
313        // A dangling SYMLINK lands here too — `read_to_string` follows it and
314        // reports NotFound, while `hard_link` does not follow it and reports
315        // EEXIST — so the retry path must be able to say "still not readable"
316        // rather than loop.
317        if err.kind() == std::io::ErrorKind::AlreadyExists {
318            return Ok(false);
319        }
320        return Err(err)
321            .with_context(|| format!("creating the signing-key file at {}", path.display()));
322    }
323    // The contents now live at `path`; dropping `staged` unlinks only the
324    // temporary second name for the same inode.
325    drop(staged);
326    sync_parent_dir(path);
327    Ok(true)
328}
329
330/// Replace an EXISTING key file **atomically**, keeping owner-only permissions.
331///
332/// Symlinks are resolved first so the rename replaces the **target** rather than
333/// the link. Renaming over the link itself would break an operator's deliberate
334/// indirection and — worse during a plaintext migration — leave the unencrypted
335/// private JWK sitting at the old target.
336fn rewrite_owner_only(path: &Path, contents: &str) -> Result<()> {
337    let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
338    let staged = Staged::write(&target, contents)?;
339    fs::rename(staged.path(), &target)
340        .with_context(|| format!("replacing the signing-key file at {}", target.display()))?;
341    // The temp name no longer exists; the rename consumed it.
342    staged.keep();
343    sync_parent_dir(&target);
344    Ok(())
345}
346
347/// Best-effort fsync of the containing directory, so a rename survives a crash.
348/// Failure is not fatal — the data is already durable, only the link is at risk.
349fn sync_parent_dir(path: &Path) {
350    if let Some(dir) = path.parent() {
351        if let Ok(handle) = fs::File::open(dir) {
352            let _ = handle.sync_all();
353        }
354    }
355}
356
357/// Load the signing key from `path`, generating and persisting one if absent.
358///
359/// A file that exists but cannot be decrypted or parsed is a hard ERROR, never a
360/// silent regeneration: the key anchors `client_id`, so quietly replacing it
361/// would invalidate every in-flight authorization and every cached JWKS entry.
362/// Refusing to boot is the safer failure.
363///
364/// Migrate-on-read: a legacy PLAINTEXT key file is loaded and immediately
365/// re-written encrypted, without changing the key.
366pub fn load_or_create(path: &Path, codec: &Codec, kid: &str) -> Result<SigningKey> {
367    // Read directly rather than exists()-then-read: a check-then-use pair is a
368    // filesystem race. A missing file surfaces as NotFound.
369    let raw = match fs::read_to_string(path) {
370        Ok(raw) => Some(raw),
371        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
372        Err(err) => {
373            return Err(err)
374                .with_context(|| format!("reading the signing-key file at {}", path.display()))
375        }
376    };
377
378    if let Some(raw) = raw {
379        return adopt_existing(path, &raw, codec, kid);
380    }
381
382    let key = SigningKey::generate(kid);
383    if write_new_owner_only(path, &codec.encrypt(&key.to_jwk_json()?))? {
384        return Ok(key);
385    }
386
387    // Someone else created the file between our read and our link. Their key is
388    // the one on disk and therefore the one the JWKS will publish, so read it
389    // rather than returning the one we generated and threw away — two replicas
390    // holding different keys under the same `kid` is precisely the split this
391    // no-clobber guard exists to prevent.
392    //
393    // This goes through the SAME adoption path as a first read, deliberately. A
394    // second copy of that logic drifted immediately: it lost the `trim` that
395    // tolerates a hand-written file, so a winner's file with a trailing newline
396    // failed to boot — but only for whoever lost the race, making it
397    // non-deterministic — and it lost the plaintext re-encryption, so a legacy
398    // key file adopted this way stayed in plaintext on disk despite a configured
399    // encryption key.
400    let raw = match fs::read_to_string(path) {
401        Ok(raw) => raw,
402        // `read_to_string` follows a symlink and `hard_link` does not, so a
403        // DANGLING SYMLINK at `path` reaches this branch: NotFound on the read,
404        // EEXIST on the link. Naming the race here would be a lie.
405        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
406            bail!(
407                "the signing-key path {} exists but cannot be read; it is most likely a \
408                 dangling symlink, which must be removed or repointed by hand",
409                path.display()
410            )
411        }
412        Err(err) => {
413            return Err(err).with_context(|| {
414                format!(
415                    "re-reading the signing-key file at {} after losing the creation race",
416                    path.display()
417                )
418            })
419        }
420    };
421    adopt_existing(path, &raw, codec, kid)
422}
423
424/// Adopt a signing key that already exists on disk.
425///
426/// The single place a stored key becomes a usable one, so the first-read path
427/// and the lost-race path cannot diverge in what they tolerate or what they
428/// migrate.
429fn adopt_existing(path: &Path, raw: &str, codec: &Codec, kid: &str) -> Result<SigningKey> {
430    // Trimmed because a key file is something an operator may have written by
431    // hand or with `echo`, and a trailing newline is not a corrupt key.
432    let raw = raw.trim();
433    let plaintext = codec.maybe_decrypt(raw).with_context(|| {
434        format!(
435            "decrypting the signing-key file at {} -- refusing to generate a \
436             replacement, since that would rotate the client's identity",
437            path.display()
438        )
439    })?;
440    let key = SigningKey::from_jwk_json(&plaintext, kid)?;
441    if !Aead::is_ciphertext(raw) {
442        // Upgrade a pre-encryption file in place. Reached for a key the Node
443        // sidecar wrote, which is plaintext.
444        rewrite_owner_only(path, &codec.encrypt(&plaintext))?;
445    }
446    Ok(key)
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::oauth::crypto::{Aead, Codec};
453
454    const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
455    const KID: &str = "featherreader-oauth-1";
456
457    /// A P-256 keypair and its RFC 7638 thumbprint, generated by `jose` 5.10 —
458    /// an INDEPENDENT implementation, not this one. Regenerate with:
459    /// ```text
460    /// node --input-type=module -e 'import {generateKeyPair,exportJWK,calculateJwkThumbprint} from "jose";
461    ///   const {publicKey,privateKey}=await generateKeyPair("ES256",{extractable:true});
462    ///   console.log(JSON.stringify({pub:await exportJWK(publicKey),
463    ///     priv:await exportJWK(privateKey),
464    ///     tp:await calculateJwkThumbprint(await exportJWK(publicKey),"sha256")}))'
465    /// ```
466    const JOSE_PRIVATE_JWK: &str = r#"{
467        "kty": "EC",
468        "x": "HWngJQsJ6v606UgaeEf0Xv_Fe3c4MwChe3ouzCDZf3I",
469        "y": "wwvoKJJUKd57bdQ3f3GpVuW-0-1MI_FhMt86Q9M95Ig",
470        "crv": "P-256",
471        "d": "ltBp9dkK7xkLm9VXOd6CMiLdFRKWQwVrN0Vf8QwC3a4"
472    }"#;
473    const JOSE_THUMBPRINT: &str = "nfjQX8hSYRpE05ADhZk6PVsPatJ6MqzqvzYxlL-kMC8";
474
475    // ── thumbprint ───────────────────────────────────────────────────────────
476
477    /// **The property the earlier WIP got wrong.** It hashed the raw JWK *string*,
478    /// which depends on whitespace, member order and any extra members, and would
479    /// happily include the private `d`. RFC 7638 hashes canonical JSON of exactly
480    /// {crv, kty, x, y}. A value that does not match this is rejected by every PDS.
481    #[test]
482    fn thumbprint_matches_an_independent_rfc7638_implementation() {
483        let key = SigningKey::from_jwk_json(JOSE_PRIVATE_JWK, KID).unwrap();
484        assert_eq!(key.thumbprint().unwrap(), JOSE_THUMBPRINT);
485    }
486
487    /// The thumbprint identifies the PUBLIC key, so the private JWK and the
488    /// public JWK of the same keypair must produce the same value.
489    #[test]
490    fn thumbprint_is_identical_for_the_private_and_public_halves() {
491        let key = SigningKey::from_jwk_json(JOSE_PRIVATE_JWK, KID).unwrap();
492        let public_only = serde_json::to_string(&key.public_jwk().unwrap()).unwrap();
493        let reloaded = SigningKey::public_thumbprint_of(&public_only).unwrap();
494        assert_eq!(reloaded, key.thumbprint().unwrap());
495        assert_eq!(reloaded, JOSE_THUMBPRINT);
496    }
497
498    /// Canonicalization means member order and whitespace in the INPUT cannot
499    /// change the output — the failure mode of hashing the raw string.
500    #[test]
501    fn thumbprint_ignores_member_order_and_whitespace() {
502        let reordered = r#"{"y":"wwvoKJJUKd57bdQ3f3GpVuW-0-1MI_FhMt86Q9M95Ig","d":"ltBp9dkK7xkLm9VXOd6CMiLdFRKWQwVrN0Vf8QwC3a4","crv":"P-256","kty":"EC","x":"HWngJQsJ6v606UgaeEf0Xv_Fe3c4MwChe3ouzCDZf3I"}"#;
503        let key = SigningKey::from_jwk_json(reordered, KID).unwrap();
504        assert_eq!(key.thumbprint().unwrap(), JOSE_THUMBPRINT);
505    }
506
507    // ── generation and round-trip ────────────────────────────────────────────
508
509    /// The canonical form must be real JSON with real escaping. Building it by
510    /// string interpolation produced invalid JSON — and therefore a thumbprint
511    /// no other implementation reproduces — for any member containing a quote,
512    /// a backslash or a control character.
513    ///
514    /// Such members cannot occur in a well-formed EC JWK (`x`/`y` are base64url),
515    /// so the fix is to reject them rather than hash them. That matters because
516    /// this function takes JWKs from REMOTE parties.
517    #[test]
518    fn public_thumbprint_of_rejects_members_that_are_not_base64url() {
519        for x in [r#"A\"A"#, r#"A\\A"#, "A A", "A+A", "A/A", "AAA=", "", "é"] {
520            let jwk = serde_json::json!({"kty":"EC","crv":"P-256","x":x,"y":"BBB"});
521            assert!(
522                SigningKey::public_thumbprint_of(&jwk.to_string()).is_err(),
523                "accepted a non-base64url x: {x:?}"
524            );
525        }
526    }
527
528    #[test]
529    fn public_thumbprint_of_rejects_malformed_or_non_ec_jwks() {
530        for jwk in [
531            r#"{"kty":"RSA","crv":"P-256","x":"AAA","y":"BBB"}"#,
532            r#"{"kty":"EC","crv":"P-521","x":"AAA","y":"BBB"}"#,
533            r#"{"kty":"EC","crv":"P-256","x":"AAA"}"#,
534            r#"{"kty":"EC","crv":"P-256"}"#,
535            r#"{"kty":"EC","crv":"P-256","x":123,"y":"BBB"}"#,
536            "not json",
537            "[]",
538        ] {
539            assert!(
540                SigningKey::public_thumbprint_of(jwk).is_err(),
541                "accepted {jwk}"
542            );
543        }
544    }
545
546    #[test]
547    fn generate_produces_distinct_keys() {
548        let a = SigningKey::generate(KID);
549        let b = SigningKey::generate(KID);
550        assert_ne!(a.thumbprint().unwrap(), b.thumbprint().unwrap());
551    }
552
553    #[test]
554    fn a_generated_key_round_trips_through_its_jwk() {
555        let key = SigningKey::generate(KID);
556        let json = key.to_jwk_json().unwrap();
557        let back = SigningKey::from_jwk_json(&json, KID).unwrap();
558        assert_eq!(back.thumbprint().unwrap(), key.thumbprint().unwrap());
559    }
560
561    /// The persisted JWK is the PRIVATE one (it has to be — it is the signing
562    /// key), and it carries the kid so a rollback to the sidecar finds it.
563    #[test]
564    fn the_persisted_jwk_carries_the_private_scalar_and_the_kid() {
565        let key = SigningKey::generate(KID);
566        let v: serde_json::Value = serde_json::from_str(&key.to_jwk_json().unwrap()).unwrap();
567        assert!(
568            v.get("d").is_some(),
569            "the signing key must persist its scalar"
570        );
571        assert_eq!(v["kid"], KID);
572        assert_eq!(v["kty"], "EC");
573        assert_eq!(v["crv"], "P-256");
574    }
575
576    // ── what gets published ──────────────────────────────────────────────────
577
578    /// The single most important leak to not have: `d` must never reach the
579    /// JWKS endpoint.
580    #[test]
581    fn the_public_jwk_and_jwks_never_contain_the_private_scalar() {
582        let key = SigningKey::generate(KID);
583        let public = key.public_jwk().unwrap();
584        assert!(public.get("d").is_none());
585
586        let jwks = key.jwks_document().unwrap();
587        let rendered = serde_json::to_string(&jwks).unwrap();
588        assert!(
589            !rendered.contains("\"d\""),
590            "JWKS leaked the private scalar: {rendered}"
591        );
592        // **The scalar of the key UNDER TEST, not a literal.**
593        //
594        // This line used to hard-code the `d` of the fixture JWK above. The key
595        // here is `SigningKey::generate`, freshly random every run, so that
596        // literal could never appear whatever the code did — the assertion was
597        // unconditionally true.
598        //
599        // It also covers what the `"d"` check above cannot: material leaking
600        // under some OTHER member name. A scalar copied into `x` passes a search
601        // for `"d"` and fails this.
602        let private: serde_json::Value = serde_json::from_str(&key.to_jwk_json().unwrap()).unwrap();
603        let scalar = private["d"].as_str().expect("the private JWK has a scalar");
604        assert!(
605            !rendered.contains(scalar),
606            "JWKS leaked the private scalar: {rendered}"
607        );
608    }
609
610    #[test]
611    fn the_jwks_document_is_a_keys_array_with_the_verification_metadata() {
612        let key = SigningKey::from_jwk_json(JOSE_PRIVATE_JWK, KID).unwrap();
613        let jwks = key.jwks_document().unwrap();
614        let entry = &jwks["keys"][0];
615        assert_eq!(jwks["keys"].as_array().unwrap().len(), 1);
616        assert_eq!(entry["kty"], "EC");
617        assert_eq!(entry["crv"], "P-256");
618        assert_eq!(entry["kid"], KID);
619        assert_eq!(entry["alg"], "ES256");
620        assert_eq!(entry["use"], "sig");
621        assert_eq!(entry["x"], "HWngJQsJ6v606UgaeEf0Xv_Fe3c4MwChe3ouzCDZf3I");
622    }
623
624    // ── persistence ──────────────────────────────────────────────────────────
625
626    fn tmp_path(name: &str) -> std::path::PathBuf {
627        let mut p = std::env::temp_dir();
628        p.push(format!("fr-oauth-key-test-{}-{name}", std::process::id()));
629        let _ = std::fs::remove_file(&p);
630        p
631    }
632
633    #[test]
634    fn load_or_create_generates_once_then_reloads_the_same_key() {
635        let path = tmp_path("reload");
636        let codec = Codec::new(Some(KEY)).unwrap();
637
638        let first = load_or_create(&path, &codec, KID).unwrap();
639        let second = load_or_create(&path, &codec, KID).unwrap();
640        assert_eq!(
641            first.thumbprint().unwrap(),
642            second.thumbprint().unwrap(),
643            "a restart must not rotate the client's signing key"
644        );
645        let _ = std::fs::remove_file(&path);
646    }
647
648    #[test]
649    fn the_key_file_is_encrypted_at_rest() {
650        let path = tmp_path("encrypted");
651        let codec = Codec::new(Some(KEY)).unwrap();
652        let key = load_or_create(&path, &codec, KID).unwrap();
653
654        let raw = std::fs::read_to_string(&path).unwrap();
655        assert!(Aead::is_ciphertext(raw.trim()), "key file is not encrypted");
656        // Assert the SCALAR is absent, not the string `"d"`: the file is
657        // base64url, which can never contain a quote, so checking for `"d"`
658        // would pass even with no encryption at all.
659        let scalar = serde_json::from_str::<serde_json::Value>(&key.to_jwk_json().unwrap())
660            .unwrap()["d"]
661            .as_str()
662            .unwrap()
663            .to_string();
664        assert!(!raw.contains(&scalar), "private scalar found on disk");
665        // And it really is the same key underneath.
666        let plain = codec.maybe_decrypt(raw.trim()).unwrap();
667        let same = SigningKey::from_jwk_json(&plain, KID).unwrap();
668        assert_eq!(same.thumbprint().unwrap(), key.thumbprint().unwrap());
669        let _ = std::fs::remove_file(&path);
670    }
671
672    /// Migrate-on-read: a key file written before encryption was switched on is
673    /// loaded as plaintext and re-written encrypted, without changing the key.
674    #[test]
675    fn a_legacy_plaintext_key_file_is_migrated_to_ciphertext_in_place() {
676        let path = tmp_path("migrate");
677        let codec = Codec::new(Some(KEY)).unwrap();
678
679        let original = SigningKey::generate(KID);
680        std::fs::write(&path, original.to_jwk_json().unwrap()).unwrap();
681
682        let loaded = load_or_create(&path, &codec, KID).unwrap();
683        assert_eq!(
684            loaded.thumbprint().unwrap(),
685            original.thumbprint().unwrap(),
686            "migration must not rotate the key"
687        );
688        let raw = std::fs::read_to_string(&path).unwrap();
689        assert!(
690            Aead::is_ciphertext(raw.trim()),
691            "file was not upgraded to ciphertext"
692        );
693        let _ = std::fs::remove_file(&path);
694    }
695
696    /// A key written by the sidecar (a bare jose JWK, no kid) must load, since
697    /// that is the rollback/rollforward path.
698    #[test]
699    fn a_key_file_written_by_the_node_sidecar_loads() {
700        let path = tmp_path("sidecar");
701        let codec = Codec::new(Some(KEY)).unwrap();
702        std::fs::write(&path, codec.encrypt(JOSE_PRIVATE_JWK)).unwrap();
703
704        let loaded = load_or_create(&path, &codec, KID).unwrap();
705        assert_eq!(loaded.thumbprint().unwrap(), JOSE_THUMBPRINT);
706        let _ = std::fs::remove_file(&path);
707    }
708
709    /// A corrupt or wrong-key file must fail LOUDLY. Silently generating a
710    /// replacement would rotate the client's identity and invalidate every
711    /// in-flight authorization, which is far worse than refusing to boot.
712    #[test]
713    fn an_undecryptable_key_file_is_an_error_not_a_silent_regeneration() {
714        let path = tmp_path("corrupt");
715        let codec = Codec::new(Some(KEY)).unwrap();
716        let other = Codec::new(Some("a-completely-different-passphrase")).unwrap();
717        let on_disk = other.encrypt(JOSE_PRIVATE_JWK);
718        std::fs::write(&path, &on_disk).unwrap();
719
720        assert!(load_or_create(&path, &codec, KID).is_err());
721        // And the unreadable key is LEFT ALONE. Replacing or deleting it would
722        // discard a key that is merely locked behind the wrong passphrase.
723        assert_eq!(
724            std::fs::read_to_string(&path).unwrap(),
725            on_disk,
726            "the key file was modified on a decrypt failure"
727        );
728        let _ = std::fs::remove_file(&path);
729    }
730
731    /// The persisted private JWK must use `key_ops`, not `use`. The sidecar
732    /// writes `key_ops: ["sign"]`, and `jose` already warns that a private JWK
733    /// carrying `use` will be rejected in future — which would break the
734    /// rollback path this format exists to preserve.
735    #[test]
736    fn the_private_jwk_uses_key_ops_rather_than_use() {
737        let key = SigningKey::generate(KID);
738        let v: serde_json::Value = serde_json::from_str(&key.to_jwk_json().unwrap()).unwrap();
739        assert_eq!(v["key_ops"], serde_json::json!(["sign"]));
740        assert!(
741            v.get("use").is_none(),
742            "private JWK carries `use`, which jose deprecates"
743        );
744        // The PUBLIC half is a JWKS entry, where `use: sig` is the norm.
745        assert_eq!(key.public_jwk().unwrap()["use"], "sig");
746    }
747
748    /// Migration rewrites an existing file. Doing that with truncate-then-write
749    /// leaves a zero-length file if the process dies in between — and since an
750    /// unreadable key file is deliberately a hard error, that would be a
751    /// permanent boot failure. A temp file plus rename makes it atomic.
752    #[test]
753    fn migration_leaves_no_temporary_file_behind() {
754        let path = tmp_path("atomic");
755        let codec = Codec::new(Some(KEY)).unwrap();
756        let original = SigningKey::generate(KID);
757        std::fs::write(&path, original.to_jwk_json().unwrap()).unwrap();
758
759        load_or_create(&path, &codec, KID).unwrap();
760
761        let dir = path.parent().unwrap();
762        let leftovers: Vec<_> = std::fs::read_dir(dir)
763            .unwrap()
764            .filter_map(|e| e.ok())
765            .map(|e| e.file_name().to_string_lossy().to_string())
766            .filter(|n| {
767                n.starts_with(path.file_name().unwrap().to_str().unwrap())
768                    && n != path.file_name().unwrap().to_str().unwrap()
769            })
770            .collect();
771        assert!(
772            leftovers.is_empty(),
773            "temp files left behind: {leftovers:?}"
774        );
775        let _ = std::fs::remove_file(&path);
776    }
777
778    #[test]
779    fn a_garbage_key_file_is_an_error() {
780        let path = tmp_path("garbage");
781        let codec = Codec::new(Some(KEY)).unwrap();
782        std::fs::write(&path, codec.encrypt("{\"kty\":\"EC\",\"crv\":\"P-256\"}")).unwrap();
783        assert!(load_or_create(&path, &codec, KID).is_err());
784        let _ = std::fs::remove_file(&path);
785    }
786
787    /// **A symlinked key path must survive migration.** Replacing the link with
788    /// a regular file breaks the operator's indirection AND strands the
789    /// unencrypted private JWK at the old target — the exact opposite of what
790    /// migrating to ciphertext is for.
791    #[cfg(unix)]
792    #[test]
793    fn migrating_through_a_symlink_rewrites_the_target_not_the_link() {
794        let target = tmp_path("symlink-target");
795        let link = tmp_path("symlink-link");
796        let _ = std::fs::remove_file(&link);
797        let codec = Codec::new(Some(KEY)).unwrap();
798
799        let original = SigningKey::generate(KID);
800        std::fs::write(&target, original.to_jwk_json().unwrap()).unwrap();
801        std::os::unix::fs::symlink(&target, &link).unwrap();
802
803        let loaded = load_or_create(&link, &codec, KID).unwrap();
804        assert_eq!(loaded.thumbprint().unwrap(), original.thumbprint().unwrap());
805
806        assert!(
807            std::fs::symlink_metadata(&link)
808                .unwrap()
809                .file_type()
810                .is_symlink(),
811            "the symlink was replaced by a regular file"
812        );
813        let target_contents = std::fs::read_to_string(&target).unwrap();
814        assert!(
815            Aead::is_ciphertext(target_contents.trim()),
816            "the real target still holds plaintext after migration"
817        );
818        let _ = std::fs::remove_file(&link);
819        let _ = std::fs::remove_file(&target);
820    }
821
822    /// Two rewrites in a row must not collide on a shared temp path, and must
823    /// leave nothing behind.
824    #[cfg(unix)]
825    #[test]
826    fn repeated_migrations_leave_no_temporary_files() {
827        let path = tmp_path("repeat");
828        let codec = Codec::new(Some(KEY)).unwrap();
829        let original = SigningKey::generate(KID);
830
831        for _ in 0..3 {
832            // Force the migrate-on-read path each time by writing plaintext.
833            std::fs::write(&path, original.to_jwk_json().unwrap()).unwrap();
834            let loaded = load_or_create(&path, &codec, KID).unwrap();
835            assert_eq!(loaded.thumbprint().unwrap(), original.thumbprint().unwrap());
836        }
837
838        let name = path.file_name().unwrap().to_str().unwrap().to_string();
839        let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
840            .unwrap()
841            .filter_map(|e| e.ok())
842            .map(|e| e.file_name().to_string_lossy().to_string())
843            .filter(|n| n.starts_with(&name) && *n != name)
844            .collect();
845        assert!(
846            leftovers.is_empty(),
847            "temp files left behind: {leftovers:?}"
848        );
849        let _ = std::fs::remove_file(&path);
850    }
851
852    #[cfg(unix)]
853    #[test]
854    fn a_created_key_file_is_not_world_readable() {
855        use std::os::unix::fs::PermissionsExt;
856        let path = tmp_path("mode");
857        let codec = Codec::new(Some(KEY)).unwrap();
858        load_or_create(&path, &codec, KID).unwrap();
859
860        // Assert no group/other bits rather than an exact 0600: `OpenOptions::mode`
861        // is masked by umask, so a umask of 0o177 legitimately yields 0o400.
862        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
863        assert_eq!(
864            mode & 0o077,
865            0,
866            "key file mode {mode:o} is group/world readable"
867        );
868        let _ = std::fs::remove_file(&path);
869    }
870
871    /// **The race loser adopts the winner's key, not the one it threw away.**
872    ///
873    /// Two replicas starting together both find no file, both generate a key,
874    /// and both try to link. The loser must read back what is on disk — two
875    /// processes holding different keys under one `kid` is exactly the split the
876    /// no-clobber guard exists to prevent. A mutation returning `Ok(true)` on
877    /// EEXIST, so the loser keeps its own discarded key, passed the whole suite.
878    #[test]
879    fn a_lost_creation_race_adopts_the_key_on_disk() {
880        let dir = std::env::temp_dir().join(format!("fr-race-{}", std::process::id()));
881        std::fs::create_dir_all(&dir).unwrap();
882        let path = dir.join("key.json");
883        let _ = std::fs::remove_file(&path);
884        let codec = Codec::new(None).unwrap();
885
886        // The winner.
887        let winner = load_or_create(&path, &codec, "kid").unwrap();
888        // A second caller now takes the same path: the file exists, so it must
889        // return the SAME key rather than minting one.
890        let loser = load_or_create(&path, &codec, "kid").unwrap();
891        assert_eq!(
892            winner.thumbprint().unwrap(),
893            loser.thumbprint().unwrap(),
894            "the second caller returned a different key than the one on disk"
895        );
896
897        // And the genuine race: many threads from nothing converge on one key.
898        let _ = std::fs::remove_file(&path);
899        let keys: Vec<String> = std::thread::scope(|s| {
900            (0..8)
901                .map(|_| {
902                    let p = path.clone();
903                    let c = &codec;
904                    s.spawn(move || load_or_create(&p, c, "kid").unwrap().thumbprint().unwrap())
905                })
906                .collect::<Vec<_>>()
907                .into_iter()
908                .map(|h| h.join().unwrap())
909                .collect()
910        });
911        let distinct: std::collections::HashSet<_> = keys.iter().collect();
912        assert_eq!(distinct.len(), 1, "the racers ended up with different keys");
913
914        let _ = std::fs::remove_dir_all(&dir);
915    }
916
917    /// **A key file with a trailing newline still loads.**
918    ///
919    /// A key file is something an operator may have written by hand or with
920    /// `echo`. The `trim` that tolerates it was added as a cold-review fix, and
921    /// removing it passed every test — no test ever wrote a file with a trailing
922    /// newline.
923    #[test]
924    fn a_key_file_with_trailing_whitespace_still_loads() {
925        let dir = std::env::temp_dir().join(format!("fr-trim-{}", std::process::id()));
926        std::fs::create_dir_all(&dir).unwrap();
927        let path = dir.join("key.json");
928        let codec = Codec::new(Some(&"a".repeat(43))).unwrap();
929
930        let key = SigningKey::generate("kid");
931        std::fs::write(
932            &path,
933            format!("{}\n", codec.encrypt(&key.to_jwk_json().unwrap())),
934        )
935        .unwrap();
936
937        let loaded =
938            load_or_create(&path, &codec, "kid").expect("a trailing newline is not a corrupt key");
939        assert_eq!(loaded.thumbprint().unwrap(), key.thumbprint().unwrap());
940        let _ = std::fs::remove_dir_all(&dir);
941    }
942
943    /// The CREATE path leaves no temporary file behind either. The two existing
944    /// cleanup tests both force the MIGRATE path by pre-writing plaintext, so a
945    /// leak on creation went unnoticed.
946    #[test]
947    fn creating_a_key_leaves_no_temporary_file_behind() {
948        let dir = std::env::temp_dir().join(format!("fr-tmp-{}", std::process::id()));
949        std::fs::create_dir_all(&dir).unwrap();
950        let path = dir.join("key.json");
951        let codec = Codec::new(None).unwrap();
952
953        load_or_create(&path, &codec, "kid").unwrap();
954
955        let leftovers: Vec<_> = std::fs::read_dir(&dir)
956            .unwrap()
957            .filter_map(|e| e.ok())
958            .map(|e| e.file_name().to_string_lossy().into_owned())
959            .filter(|n| n != "key.json")
960            .collect();
961        assert!(
962            leftovers.is_empty(),
963            "temporary files left behind: {leftovers:?}"
964        );
965        let _ = std::fs::remove_dir_all(&dir);
966    }
967}