use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use p256::{
SecretKey,
elliptic_curve::sec1::ToEncodedPoint as _,
pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _, LineEnding},
};
use serde::Serialize;
use serde_json::{Value, json};
use sqlx::{PgPool, Row as _};
use zeroize::Zeroizing;
use crate::error::{AuthError, Result};
mod live;
mod signer;
pub use live::LiveKeyRing;
pub use signer::{AccessTokenSigner, RingSigner};
pub const SIGNING_KEY_SCHEMA_SQL: &str = concat!(
claim_auth_schema!(),
r"
-- One row per signing key the first-party issuer has made. The private key is sealed
-- by the server's key-encryption key; the public half is the JWK the issuer publishes.
-- A row with no `retired_at` is the active key; there is never more than one.
CREATE TABLE IF NOT EXISTS auth.signing_key (
kid TEXT PRIMARY KEY,
alg TEXT NOT NULL CHECK (alg = 'ES256'),
public_jwk JSONB NOT NULL,
private_key_enc BYTEA NOT NULL,
created_at BIGINT NOT NULL,
retired_at BIGINT
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_signing_key_one_active
ON auth.signing_key ((true)) WHERE retired_at IS NULL;
REVOKE ALL ON auth.signing_key FROM PUBLIC;
"
);
pub const DEFAULT_KEK_ENV: &str = "FRAISEQL_AUTH_SIGNING_KEK";
pub const ROTATION_PERIOD_SECS: u64 = 30 * 24 * 60 * 60;
const UNDEFINED_TABLE: &str = "42P01";
const RING_LOCK: i64 = 0x6672_6169_7365_6b72;
pub trait KeyWrap: Send + Sync + fmt::Debug {
fn seal(&self, plaintext: &str, aad: &str) -> Result<Vec<u8>>;
fn open(&self, ciphertext: &[u8], aad: &str) -> Result<String>;
}
fn aad(kid: &str) -> String {
format!("fraiseql/auth.signing_key/v1/{kid}")
}
pub fn key_encryption_key_from_env(var: &str) -> Result<Zeroizing<[u8; 32]>> {
let value = Zeroizing::new(std::env::var(var).map_err(|_| AuthError::ConfigError {
message: format!(
"the signing keys' key-encryption key is read from {var}, which is not set: set it \
to 64 hex digits (32 random bytes)"
),
})?);
key_encryption_key_from_hex(var, value.trim())
}
pub fn key_encryption_key_from_hex(var: &str, hex_digits: &str) -> Result<Zeroizing<[u8; 32]>> {
let refuse = |why: &str| AuthError::ConfigError {
message: format!(
"{var} must be 64 hex digits (32 random bytes) to encrypt the signing keys; {why}"
),
};
let bytes = Zeroizing::new(hex::decode(hex_digits).map_err(|_| refuse("it is not hex"))?);
let mut key = Zeroizing::new([0_u8; 32]);
if bytes.len() != key.len() {
return Err(refuse(&format!("it decodes to {} bytes", bytes.len())));
}
key.copy_from_slice(&bytes);
Ok(key)
}
pub struct SigningKey {
kid: String,
encoding: EncodingKey,
jwk: Value,
}
impl fmt::Debug for SigningKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SigningKey")
.field("kid", &self.kid)
.field("encoding", &"<redacted>")
.finish_non_exhaustive()
}
}
impl SigningKey {
fn generate() -> Result<(Self, Zeroizing<String>)> {
let secret = loop {
if let Ok(secret) = SecretKey::from_slice(&rand::random::<[u8; 32]>()) {
break secret;
}
};
let pem = secret.to_pkcs8_pem(LineEnding::LF).map_err(|e| internal("encode", &e))?;
let pem = Zeroizing::new(pem.to_string());
let kid = uuid::Uuid::new_v4().simple().to_string();
Ok((Self::from_pem(kid, &pem)?, pem))
}
fn from_pem(kid: String, pem: &str) -> Result<Self> {
let secret = SecretKey::from_pkcs8_pem(pem).map_err(|e| internal("decode", &e))?;
let encoding =
EncodingKey::from_ec_pem(pem.as_bytes()).map_err(|e| internal("load", &e))?;
let point = secret.public_key().to_encoded_point(false);
let (Some(x), Some(y)) = (point.x(), point.y()) else {
return Err(internal("publish", &"an uncompressed point has no coordinates"));
};
let jwk = json!({
"kty": "EC",
"crv": "P-256",
"alg": "ES256",
"use": "sig",
"kid": kid,
"x": URL_SAFE_NO_PAD.encode(x),
"y": URL_SAFE_NO_PAD.encode(y),
});
Ok(Self { kid, encoding, jwk })
}
#[must_use]
pub fn kid(&self) -> &str {
&self.kid
}
#[must_use]
pub const fn jwk(&self) -> &Value {
&self.jwk
}
pub fn sign(&self, typ: &str, claims: &impl Serialize) -> Result<String> {
let mut header = Header::new(Algorithm::ES256);
header.typ = Some(typ.to_string());
header.kid = Some(self.kid.clone());
jsonwebtoken::encode(&header, claims, &self.encoding).map_err(|e| internal("sign with", &e))
}
}
fn internal(doing: &str, e: &impl fmt::Display) -> AuthError {
AuthError::Internal {
message: format!("failed to {doing} a signing key: {e}"),
}
}
fn db_error(doing: &str, e: &sqlx::Error) -> AuthError {
AuthError::DatabaseError {
message: format!("failed to {doing}: {e}"),
}
}
#[derive(Debug)]
pub struct KeyRing {
active: SigningKey,
published: Vec<Value>,
}
impl KeyRing {
#[must_use]
pub const fn active(&self) -> &SigningKey {
&self.active
}
#[must_use]
pub fn jwks(&self) -> Value {
json!({ "keys": self.published })
}
#[must_use]
pub fn published_key(&self, kid: &str) -> Option<&Value> {
self.published
.iter()
.find(|jwk| jwk.get("kid").and_then(Value::as_str) == Some(kid))
}
#[must_use]
pub const fn published_count(&self) -> usize {
self.published.len()
}
pub fn kids(&self) -> impl Iterator<Item = &str> {
self.published.iter().filter_map(|jwk| jwk.get("kid").and_then(Value::as_str))
}
}
pub struct PgSigningKeyStore {
db: PgPool,
wrap: std::sync::Arc<dyn KeyWrap>,
}
impl fmt::Debug for PgSigningKeyStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PgSigningKeyStore")
.field("wrap", &self.wrap)
.finish_non_exhaustive()
}
}
impl PgSigningKeyStore {
#[must_use]
pub fn new(db: PgPool, wrap: std::sync::Arc<dyn KeyWrap>) -> Self {
Self { db, wrap }
}
pub async fn init(&self) -> Result<()> {
sqlx::raw_sql(SIGNING_KEY_SCHEMA_SQL)
.execute(&self.db)
.await
.map_err(|e| db_error("create auth.signing_key", &e))?;
Ok(())
}
pub async fn ensure_active(&self, now: u64) -> Result<Option<String>> {
let mut tx = self.db.begin().await.map_err(|e| db_error("begin a key change", &e))?;
lock_ring(&mut tx).await?;
let active = self.active(&mut tx).await?;
let due = active.as_ref().is_none_or(|active| {
now.saturating_sub(active.created_at.cast_unsigned()) >= ROTATION_PERIOD_SECS
});
if !due {
return Ok(None);
}
if let Some(active) = active {
self.open_active(&active)?;
}
let kid = self.replace_active(&mut tx, now).await?;
tx.commit().await.map_err(|e| db_error("commit a key change", &e))?;
Ok(Some(kid))
}
pub async fn rotate(&self, now: u64) -> Result<Rotation> {
let mut tx = self.db.begin().await.map_err(|e| db_error("begin a key change", &e))?;
lock_ring(&mut tx).await?;
let active = self.active(&mut tx).await?.ok_or_else(|| AuthError::ConfigError {
message: "auth.signing_key has no active key to rotate: a server with \
[auth_issuer] creates the first one when it boots"
.to_string(),
})?;
self.open_active(&active)?;
let kid = self.replace_active(&mut tx, now).await?;
tx.commit().await.map_err(|e| db_error("commit a key change", &e))?;
Ok(Rotation {
retired: active.kid,
active: kid,
})
}
async fn active(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<ActiveRow>> {
let row = sqlx::query(
"SELECT kid, private_key_enc, created_at FROM auth.signing_key \
WHERE retired_at IS NULL",
)
.fetch_optional(&mut **tx)
.await
.map_err(|e| match e.as_database_error().and_then(|db| db.code()) {
Some(code) if code == UNDEFINED_TABLE => AuthError::ConfigError {
message: "auth.signing_key does not exist: a server with [auth_issuer] \
creates it when it boots"
.to_string(),
},
_ => db_error("read the active signing key", &e),
})?;
Ok(row.map(|row| ActiveRow {
kid: row.get("kid"),
sealed: row.get("private_key_enc"),
created_at: row.get("created_at"),
}))
}
fn open_active(&self, active: &ActiveRow) -> Result<()> {
drop(Zeroizing::new(self.wrap.open(&active.sealed, &aad(&active.kid))?));
Ok(())
}
async fn replace_active(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
now: u64,
) -> Result<String> {
let (key, pem) = SigningKey::generate()?;
let sealed = self.wrap.seal(&pem, &aad(&key.kid))?;
sqlx::query("UPDATE auth.signing_key SET retired_at = $1 WHERE retired_at IS NULL")
.bind(now.cast_signed())
.execute(&mut **tx)
.await
.map_err(|e| db_error("retire the active signing key", &e))?;
sqlx::query(
"INSERT INTO auth.signing_key (kid, alg, public_jwk, private_key_enc, created_at) \
VALUES ($1, 'ES256', $2, $3, $4)",
)
.bind(&key.kid)
.bind(&key.jwk)
.bind(sealed)
.bind(now.cast_signed())
.execute(&mut **tx)
.await
.map_err(|e| db_error("insert a signing key", &e))?;
Ok(key.kid)
}
pub async fn load(&self, now: u64, publish_window_secs: u64) -> Result<KeyRing> {
let rows = sqlx::query(
"SELECT kid, public_jwk, private_key_enc, retired_at FROM auth.signing_key \
WHERE retired_at IS NULL OR retired_at > $1 \
ORDER BY retired_at DESC NULLS FIRST, created_at DESC",
)
.bind(now.saturating_sub(publish_window_secs).cast_signed())
.fetch_all(&self.db)
.await
.map_err(|e| db_error("read the signing keys", &e))?;
let mut active = None;
let mut published = Vec::with_capacity(rows.len());
for row in rows {
let kid: String = row.get("kid");
if row.get::<Option<i64>, _>("retired_at").is_none() {
let sealed: Vec<u8> = row.get("private_key_enc");
let pem = Zeroizing::new(self.wrap.open(&sealed, &aad(&kid))?);
active = Some(SigningKey::from_pem(kid, &pem)?);
}
published.push(row.get::<Value, _>("public_jwk"));
}
let active = active.ok_or_else(|| AuthError::ConfigError {
message: "auth.signing_key has no active key: the issuer must create one before \
it signs"
.to_string(),
})?;
Ok(KeyRing { active, published })
}
pub async fn sweep(&self, now: u64, publish_window_secs: u64) -> Result<u64> {
let swept = sqlx::query(
"DELETE FROM auth.signing_key WHERE retired_at IS NOT NULL AND retired_at <= $1",
)
.bind(now.saturating_sub(publish_window_secs).cast_signed())
.execute(&self.db)
.await
.map_err(|e| db_error("sweep the signing keys", &e))?
.rows_affected();
Ok(swept)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rotation {
pub retired: String,
pub active: String,
}
struct ActiveRow {
kid: String,
sealed: Vec<u8>,
created_at: i64,
}
async fn lock_ring(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> Result<()> {
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(RING_LOCK)
.execute(&mut **tx)
.await
.map_err(|e| db_error("lock the signing keys", &e))?;
Ok(())
}
#[cfg(test)]
mod tests;