use anyhow::{anyhow, bail, Context as _, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use p256::SecretKey;
use ring::digest::{digest, SHA256};
use serde_json::{json, Value};
use std::fs;
use std::io::Write as _;
use std::path::Path;
use super::crypto::{Aead, Codec};
const ALG: &str = "ES256";
fn member<'a>(jwk: &'a Value, name: &str) -> Result<&'a str> {
jwk.get(name)
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("JWK is missing the required `{name}` member"))
}
fn thumbprint_of_members(jwk: &Value) -> Result<String> {
let (crv, kty, x, y) = (
member(jwk, "crv")?,
member(jwk, "kty")?,
member(jwk, "x")?,
member(jwk, "y")?,
);
if kty != "EC" {
bail!("unsupported JWK key type {kty:?}; expected EC");
}
if crv != "P-256" {
bail!("unsupported JWK curve {crv:?}; only P-256 is supported");
}
for (name, value) in [("x", x), ("y", y)] {
if value.is_empty()
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
bail!("JWK member `{name}` is not unpadded base64url");
}
}
let canonical = serde_json::to_string(&json!({"crv": crv, "kty": kty, "x": x, "y": y}))
.context("serializing the canonical JWK")?;
Ok(URL_SAFE_NO_PAD.encode(digest(&SHA256, canonical.as_bytes()).as_ref()))
}
pub struct SigningKey {
secret: SecretKey,
kid: String,
}
impl SigningKey {
pub fn generate(kid: &str) -> Self {
for _ in 0..8 {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes)
.expect("OS CSPRNG unavailable; refusing to generate a signing key");
if let Ok(secret) = SecretKey::from_slice(&bytes) {
return Self {
secret,
kid: kid.to_string(),
};
}
}
unreachable!("8 consecutive invalid P-256 scalars is not physically plausible")
}
pub fn from_jwk_json(jwk_json: &str, kid: &str) -> Result<Self> {
let v: Value = serde_json::from_str(jwk_json).context("key file is not valid JSON")?;
if v.get("d").is_none() {
bail!("JWK has no `d` member; a signing key must be a private key");
}
let minimal = json!({
"kty": member(&v, "kty")?,
"crv": member(&v, "crv")?,
"x": member(&v, "x")?,
"y": member(&v, "y")?,
"d": member(&v, "d")?,
});
let secret = SecretKey::from_jwk_str(&minimal.to_string())
.map_err(|err| anyhow!("not a valid P-256 private JWK: {err}"))?;
let kid = v
.get("kid")
.and_then(Value::as_str)
.unwrap_or(kid)
.to_string();
Ok(Self { secret, kid })
}
pub fn to_jwk_json(&self) -> Result<String> {
let mut v: Value =
serde_json::to_value(self.secret.to_jwk()).context("serializing the private JWK")?;
let obj = v
.as_object_mut()
.ok_or_else(|| anyhow!("JWK did not serialize to an object"))?;
obj.insert("kid".into(), json!(self.kid));
obj.insert("alg".into(), json!(ALG));
obj.insert("key_ops".into(), json!(["sign"]));
serde_json::to_string(&v).context("rendering the private JWK")
}
pub fn public_jwk(&self) -> Result<Value> {
let mut v: Value = serde_json::to_value(self.secret.public_key().to_jwk())
.context("serializing the public JWK")?;
let obj = v
.as_object_mut()
.ok_or_else(|| anyhow!("JWK did not serialize to an object"))?;
obj.remove("d");
obj.insert("kid".into(), json!(self.kid));
obj.insert("alg".into(), json!(ALG));
obj.insert("use".into(), json!("sig"));
Ok(v)
}
pub fn jwks_document(&self) -> Result<Value> {
Ok(json!({ "keys": [self.public_jwk()?] }))
}
pub fn thumbprint(&self) -> Result<String> {
thumbprint_of_members(&self.public_jwk()?)
}
pub fn public_thumbprint_of(jwk_json: &str) -> Result<String> {
let v: Value = serde_json::from_str(jwk_json).context("not valid JSON")?;
thumbprint_of_members(&v)
}
pub fn kid(&self) -> &str {
&self.kid
}
pub(crate) fn secret(&self) -> &SecretKey {
&self.secret
}
}
struct Staged {
path: std::path::PathBuf,
keep: bool,
}
impl Staged {
fn write(near: &Path, contents: &str) -> Result<Self> {
let mut suffix = [0u8; 8];
getrandom::fill(&mut suffix).expect("OS CSPRNG unavailable; refusing to stage a key");
let mut name = near.as_os_str().to_os_string();
name.push(format!(
".{}.{}.tmp",
std::process::id(),
suffix
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
));
let path = std::path::PathBuf::from(name);
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
let mut file = opts
.open(&path)
.with_context(|| format!("staging a key write at {}", path.display()))?;
let staged = Self { path, keep: false };
file.write_all(contents.as_bytes())
.context("writing the staged signing-key file")?;
file.sync_all().context("flushing the staged key file")?;
Ok(staged)
}
fn path(&self) -> &Path {
&self.path
}
fn keep(mut self) {
self.keep = true;
}
}
impl Drop for Staged {
fn drop(&mut self) {
if !self.keep {
let _ = fs::remove_file(&self.path);
}
}
}
fn write_new_owner_only(path: &Path, contents: &str) -> Result<bool> {
let staged = Staged::write(path, contents)?;
if let Err(err) = fs::hard_link(staged.path(), path) {
if err.kind() == std::io::ErrorKind::AlreadyExists {
return Ok(false);
}
return Err(err)
.with_context(|| format!("creating the signing-key file at {}", path.display()));
}
drop(staged);
sync_parent_dir(path);
Ok(true)
}
fn rewrite_owner_only(path: &Path, contents: &str) -> Result<()> {
let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let staged = Staged::write(&target, contents)?;
fs::rename(staged.path(), &target)
.with_context(|| format!("replacing the signing-key file at {}", target.display()))?;
staged.keep();
sync_parent_dir(&target);
Ok(())
}
fn sync_parent_dir(path: &Path) {
if let Some(dir) = path.parent() {
if let Ok(handle) = fs::File::open(dir) {
let _ = handle.sync_all();
}
}
}
pub fn load_or_create(path: &Path, codec: &Codec, kid: &str) -> Result<SigningKey> {
let raw = match fs::read_to_string(path) {
Ok(raw) => Some(raw),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => {
return Err(err)
.with_context(|| format!("reading the signing-key file at {}", path.display()))
}
};
if let Some(raw) = raw {
return adopt_existing(path, &raw, codec, kid);
}
let key = SigningKey::generate(kid);
if write_new_owner_only(path, &codec.encrypt(&key.to_jwk_json()?))? {
return Ok(key);
}
let raw = match fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
bail!(
"the signing-key path {} exists but cannot be read; it is most likely a \
dangling symlink, which must be removed or repointed by hand",
path.display()
)
}
Err(err) => {
return Err(err).with_context(|| {
format!(
"re-reading the signing-key file at {} after losing the creation race",
path.display()
)
})
}
};
adopt_existing(path, &raw, codec, kid)
}
fn adopt_existing(path: &Path, raw: &str, codec: &Codec, kid: &str) -> Result<SigningKey> {
let raw = raw.trim();
let plaintext = codec.maybe_decrypt(raw).with_context(|| {
format!(
"decrypting the signing-key file at {} -- refusing to generate a \
replacement, since that would rotate the client's identity",
path.display()
)
})?;
let key = SigningKey::from_jwk_json(&plaintext, kid)?;
if !Aead::is_ciphertext(raw) {
rewrite_owner_only(path, &codec.encrypt(&plaintext))?;
}
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oauth::crypto::{Aead, Codec};
const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const KID: &str = "featherreader-oauth-1";
const JOSE_PRIVATE_JWK: &str = r#"{
"kty": "EC",
"x": "HWngJQsJ6v606UgaeEf0Xv_Fe3c4MwChe3ouzCDZf3I",
"y": "wwvoKJJUKd57bdQ3f3GpVuW-0-1MI_FhMt86Q9M95Ig",
"crv": "P-256",
"d": "ltBp9dkK7xkLm9VXOd6CMiLdFRKWQwVrN0Vf8QwC3a4"
}"#;
const JOSE_THUMBPRINT: &str = "nfjQX8hSYRpE05ADhZk6PVsPatJ6MqzqvzYxlL-kMC8";
#[test]
fn thumbprint_matches_an_independent_rfc7638_implementation() {
let key = SigningKey::from_jwk_json(JOSE_PRIVATE_JWK, KID).unwrap();
assert_eq!(key.thumbprint().unwrap(), JOSE_THUMBPRINT);
}
#[test]
fn thumbprint_is_identical_for_the_private_and_public_halves() {
let key = SigningKey::from_jwk_json(JOSE_PRIVATE_JWK, KID).unwrap();
let public_only = serde_json::to_string(&key.public_jwk().unwrap()).unwrap();
let reloaded = SigningKey::public_thumbprint_of(&public_only).unwrap();
assert_eq!(reloaded, key.thumbprint().unwrap());
assert_eq!(reloaded, JOSE_THUMBPRINT);
}
#[test]
fn thumbprint_ignores_member_order_and_whitespace() {
let reordered = r#"{"y":"wwvoKJJUKd57bdQ3f3GpVuW-0-1MI_FhMt86Q9M95Ig","d":"ltBp9dkK7xkLm9VXOd6CMiLdFRKWQwVrN0Vf8QwC3a4","crv":"P-256","kty":"EC","x":"HWngJQsJ6v606UgaeEf0Xv_Fe3c4MwChe3ouzCDZf3I"}"#;
let key = SigningKey::from_jwk_json(reordered, KID).unwrap();
assert_eq!(key.thumbprint().unwrap(), JOSE_THUMBPRINT);
}
#[test]
fn public_thumbprint_of_rejects_members_that_are_not_base64url() {
for x in [r#"A\"A"#, r#"A\\A"#, "A A", "A+A", "A/A", "AAA=", "", "é"] {
let jwk = serde_json::json!({"kty":"EC","crv":"P-256","x":x,"y":"BBB"});
assert!(
SigningKey::public_thumbprint_of(&jwk.to_string()).is_err(),
"accepted a non-base64url x: {x:?}"
);
}
}
#[test]
fn public_thumbprint_of_rejects_malformed_or_non_ec_jwks() {
for jwk in [
r#"{"kty":"RSA","crv":"P-256","x":"AAA","y":"BBB"}"#,
r#"{"kty":"EC","crv":"P-521","x":"AAA","y":"BBB"}"#,
r#"{"kty":"EC","crv":"P-256","x":"AAA"}"#,
r#"{"kty":"EC","crv":"P-256"}"#,
r#"{"kty":"EC","crv":"P-256","x":123,"y":"BBB"}"#,
"not json",
"[]",
] {
assert!(
SigningKey::public_thumbprint_of(jwk).is_err(),
"accepted {jwk}"
);
}
}
#[test]
fn generate_produces_distinct_keys() {
let a = SigningKey::generate(KID);
let b = SigningKey::generate(KID);
assert_ne!(a.thumbprint().unwrap(), b.thumbprint().unwrap());
}
#[test]
fn a_generated_key_round_trips_through_its_jwk() {
let key = SigningKey::generate(KID);
let json = key.to_jwk_json().unwrap();
let back = SigningKey::from_jwk_json(&json, KID).unwrap();
assert_eq!(back.thumbprint().unwrap(), key.thumbprint().unwrap());
}
#[test]
fn the_persisted_jwk_carries_the_private_scalar_and_the_kid() {
let key = SigningKey::generate(KID);
let v: serde_json::Value = serde_json::from_str(&key.to_jwk_json().unwrap()).unwrap();
assert!(
v.get("d").is_some(),
"the signing key must persist its scalar"
);
assert_eq!(v["kid"], KID);
assert_eq!(v["kty"], "EC");
assert_eq!(v["crv"], "P-256");
}
#[test]
fn the_public_jwk_and_jwks_never_contain_the_private_scalar() {
let key = SigningKey::generate(KID);
let public = key.public_jwk().unwrap();
assert!(public.get("d").is_none());
let jwks = key.jwks_document().unwrap();
let rendered = serde_json::to_string(&jwks).unwrap();
assert!(
!rendered.contains("\"d\""),
"JWKS leaked the private scalar: {rendered}"
);
let private: serde_json::Value = serde_json::from_str(&key.to_jwk_json().unwrap()).unwrap();
let scalar = private["d"].as_str().expect("the private JWK has a scalar");
assert!(
!rendered.contains(scalar),
"JWKS leaked the private scalar: {rendered}"
);
}
#[test]
fn the_jwks_document_is_a_keys_array_with_the_verification_metadata() {
let key = SigningKey::from_jwk_json(JOSE_PRIVATE_JWK, KID).unwrap();
let jwks = key.jwks_document().unwrap();
let entry = &jwks["keys"][0];
assert_eq!(jwks["keys"].as_array().unwrap().len(), 1);
assert_eq!(entry["kty"], "EC");
assert_eq!(entry["crv"], "P-256");
assert_eq!(entry["kid"], KID);
assert_eq!(entry["alg"], "ES256");
assert_eq!(entry["use"], "sig");
assert_eq!(entry["x"], "HWngJQsJ6v606UgaeEf0Xv_Fe3c4MwChe3ouzCDZf3I");
}
fn tmp_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("fr-oauth-key-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
#[test]
fn load_or_create_generates_once_then_reloads_the_same_key() {
let path = tmp_path("reload");
let codec = Codec::new(Some(KEY)).unwrap();
let first = load_or_create(&path, &codec, KID).unwrap();
let second = load_or_create(&path, &codec, KID).unwrap();
assert_eq!(
first.thumbprint().unwrap(),
second.thumbprint().unwrap(),
"a restart must not rotate the client's signing key"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn the_key_file_is_encrypted_at_rest() {
let path = tmp_path("encrypted");
let codec = Codec::new(Some(KEY)).unwrap();
let key = load_or_create(&path, &codec, KID).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(Aead::is_ciphertext(raw.trim()), "key file is not encrypted");
let scalar = serde_json::from_str::<serde_json::Value>(&key.to_jwk_json().unwrap())
.unwrap()["d"]
.as_str()
.unwrap()
.to_string();
assert!(!raw.contains(&scalar), "private scalar found on disk");
let plain = codec.maybe_decrypt(raw.trim()).unwrap();
let same = SigningKey::from_jwk_json(&plain, KID).unwrap();
assert_eq!(same.thumbprint().unwrap(), key.thumbprint().unwrap());
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_legacy_plaintext_key_file_is_migrated_to_ciphertext_in_place() {
let path = tmp_path("migrate");
let codec = Codec::new(Some(KEY)).unwrap();
let original = SigningKey::generate(KID);
std::fs::write(&path, original.to_jwk_json().unwrap()).unwrap();
let loaded = load_or_create(&path, &codec, KID).unwrap();
assert_eq!(
loaded.thumbprint().unwrap(),
original.thumbprint().unwrap(),
"migration must not rotate the key"
);
let raw = std::fs::read_to_string(&path).unwrap();
assert!(
Aead::is_ciphertext(raw.trim()),
"file was not upgraded to ciphertext"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_key_file_written_by_the_node_sidecar_loads() {
let path = tmp_path("sidecar");
let codec = Codec::new(Some(KEY)).unwrap();
std::fs::write(&path, codec.encrypt(JOSE_PRIVATE_JWK)).unwrap();
let loaded = load_or_create(&path, &codec, KID).unwrap();
assert_eq!(loaded.thumbprint().unwrap(), JOSE_THUMBPRINT);
let _ = std::fs::remove_file(&path);
}
#[test]
fn an_undecryptable_key_file_is_an_error_not_a_silent_regeneration() {
let path = tmp_path("corrupt");
let codec = Codec::new(Some(KEY)).unwrap();
let other = Codec::new(Some("a-completely-different-passphrase")).unwrap();
let on_disk = other.encrypt(JOSE_PRIVATE_JWK);
std::fs::write(&path, &on_disk).unwrap();
assert!(load_or_create(&path, &codec, KID).is_err());
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
on_disk,
"the key file was modified on a decrypt failure"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn the_private_jwk_uses_key_ops_rather_than_use() {
let key = SigningKey::generate(KID);
let v: serde_json::Value = serde_json::from_str(&key.to_jwk_json().unwrap()).unwrap();
assert_eq!(v["key_ops"], serde_json::json!(["sign"]));
assert!(
v.get("use").is_none(),
"private JWK carries `use`, which jose deprecates"
);
assert_eq!(key.public_jwk().unwrap()["use"], "sig");
}
#[test]
fn migration_leaves_no_temporary_file_behind() {
let path = tmp_path("atomic");
let codec = Codec::new(Some(KEY)).unwrap();
let original = SigningKey::generate(KID);
std::fs::write(&path, original.to_jwk_json().unwrap()).unwrap();
load_or_create(&path, &codec, KID).unwrap();
let dir = path.parent().unwrap();
let leftovers: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| {
n.starts_with(path.file_name().unwrap().to_str().unwrap())
&& n != path.file_name().unwrap().to_str().unwrap()
})
.collect();
assert!(
leftovers.is_empty(),
"temp files left behind: {leftovers:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_garbage_key_file_is_an_error() {
let path = tmp_path("garbage");
let codec = Codec::new(Some(KEY)).unwrap();
std::fs::write(&path, codec.encrypt("{\"kty\":\"EC\",\"crv\":\"P-256\"}")).unwrap();
assert!(load_or_create(&path, &codec, KID).is_err());
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn migrating_through_a_symlink_rewrites_the_target_not_the_link() {
let target = tmp_path("symlink-target");
let link = tmp_path("symlink-link");
let _ = std::fs::remove_file(&link);
let codec = Codec::new(Some(KEY)).unwrap();
let original = SigningKey::generate(KID);
std::fs::write(&target, original.to_jwk_json().unwrap()).unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
let loaded = load_or_create(&link, &codec, KID).unwrap();
assert_eq!(loaded.thumbprint().unwrap(), original.thumbprint().unwrap());
assert!(
std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink(),
"the symlink was replaced by a regular file"
);
let target_contents = std::fs::read_to_string(&target).unwrap();
assert!(
Aead::is_ciphertext(target_contents.trim()),
"the real target still holds plaintext after migration"
);
let _ = std::fs::remove_file(&link);
let _ = std::fs::remove_file(&target);
}
#[cfg(unix)]
#[test]
fn repeated_migrations_leave_no_temporary_files() {
let path = tmp_path("repeat");
let codec = Codec::new(Some(KEY)).unwrap();
let original = SigningKey::generate(KID);
for _ in 0..3 {
std::fs::write(&path, original.to_jwk_json().unwrap()).unwrap();
let loaded = load_or_create(&path, &codec, KID).unwrap();
assert_eq!(loaded.thumbprint().unwrap(), original.thumbprint().unwrap());
}
let name = path.file_name().unwrap().to_str().unwrap().to_string();
let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.starts_with(&name) && *n != name)
.collect();
assert!(
leftovers.is_empty(),
"temp files left behind: {leftovers:?}"
);
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn a_created_key_file_is_not_world_readable() {
use std::os::unix::fs::PermissionsExt;
let path = tmp_path("mode");
let codec = Codec::new(Some(KEY)).unwrap();
load_or_create(&path, &codec, KID).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode & 0o077,
0,
"key file mode {mode:o} is group/world readable"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_lost_creation_race_adopts_the_key_on_disk() {
let dir = std::env::temp_dir().join(format!("fr-race-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("key.json");
let _ = std::fs::remove_file(&path);
let codec = Codec::new(None).unwrap();
let winner = load_or_create(&path, &codec, "kid").unwrap();
let loser = load_or_create(&path, &codec, "kid").unwrap();
assert_eq!(
winner.thumbprint().unwrap(),
loser.thumbprint().unwrap(),
"the second caller returned a different key than the one on disk"
);
let _ = std::fs::remove_file(&path);
let keys: Vec<String> = std::thread::scope(|s| {
(0..8)
.map(|_| {
let p = path.clone();
let c = &codec;
s.spawn(move || load_or_create(&p, c, "kid").unwrap().thumbprint().unwrap())
})
.collect::<Vec<_>>()
.into_iter()
.map(|h| h.join().unwrap())
.collect()
});
let distinct: std::collections::HashSet<_> = keys.iter().collect();
assert_eq!(distinct.len(), 1, "the racers ended up with different keys");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_key_file_with_trailing_whitespace_still_loads() {
let dir = std::env::temp_dir().join(format!("fr-trim-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("key.json");
let codec = Codec::new(Some(&"a".repeat(43))).unwrap();
let key = SigningKey::generate("kid");
std::fs::write(
&path,
format!("{}\n", codec.encrypt(&key.to_jwk_json().unwrap())),
)
.unwrap();
let loaded =
load_or_create(&path, &codec, "kid").expect("a trailing newline is not a corrupt key");
assert_eq!(loaded.thumbprint().unwrap(), key.thumbprint().unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn creating_a_key_leaves_no_temporary_file_behind() {
let dir = std::env::temp_dir().join(format!("fr-tmp-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("key.json");
let codec = Codec::new(None).unwrap();
load_or_create(&path, &codec, "kid").unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n != "key.json")
.collect();
assert!(
leftovers.is_empty(),
"temporary files left behind: {leftovers:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}