Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261use p256::ecdsa::SigningKey;use rand::RngCore;use serde::Serialize;use sha2::{Digest, Sha256};
use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339};use crate::error::AppError;use crate::plugin::encryption::{decrypt, encrypt};
/// A generated ES256 DPoP keypair with its JWK representation.#[derive(Debug, Clone, Serialize)]pub struct DpopKeypair { /// The private key as a JWK (returned to the app, also stored encrypted). pub private_jwk: serde_json::Value, /// The public key as a JWK. pub public_jwk: serde_json::Value, /// The JWK thumbprint (RFC 7638) using SHA-256. pub thumbprint: String,}
/// Generate a new ES256 (P-256) DPoP keypair.pub fn generate_dpop_keypair() -> Result<DpopKeypair, AppError> { let mut rng_bytes = [0u8; 32]; rand::rng().fill_bytes(&mut rng_bytes);
let signing_key = SigningKey::from_bytes((&rng_bytes[..]).into()) .map_err(|e| AppError::Internal(format!("failed to generate signing key: {e}")))?;
let verifying_key = signing_key.verifying_key(); let public_point = verifying_key.to_encoded_point(false);
let x_bytes = public_point .x() .ok_or_else(|| AppError::Internal("missing x coordinate".into()))?; let y_bytes = public_point .y() .ok_or_else(|| AppError::Internal("missing y coordinate".into()))?;
use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let x_b64 = URL_SAFE_NO_PAD.encode(x_bytes); let y_b64 = URL_SAFE_NO_PAD.encode(y_bytes); let d_b64 = URL_SAFE_NO_PAD.encode(rng_bytes);
let public_jwk = serde_json::json!({ "kty": "EC", "crv": "P-256", "x": x_b64, "y": y_b64, });
let private_jwk = serde_json::json!({ "kty": "EC", "crv": "P-256", "x": x_b64, "y": y_b64, "d": d_b64, });
let thumbprint = compute_jwk_thumbprint(&public_jwk)?;
Ok(DpopKeypair { private_jwk, public_jwk, thumbprint, })}
/// Compute the JWK Thumbprint (RFC 7638) using SHA-256.////// For EC keys, the canonical JSON is: {"crv":"...","kty":"EC","x":"...","y":"..."}/// (alphabetical order of required members).pub fn compute_jwk_thumbprint(jwk: &serde_json::Value) -> Result<String, AppError> { let kty = jwk["kty"] .as_str() .ok_or_else(|| AppError::Internal("JWK missing kty".into()))?; let crv = jwk["crv"] .as_str() .ok_or_else(|| AppError::Internal("JWK missing crv".into()))?; let x = jwk["x"] .as_str() .ok_or_else(|| AppError::Internal("JWK missing x".into()))?; let y = jwk["y"] .as_str() .ok_or_else(|| AppError::Internal("JWK missing y".into()))?;
// RFC 7638: lexicographic order of required members let canonical = format!( r#"{{"crv":"{}","kty":"{}","x":"{}","y":"{}"}}"#, crv, kty, x, y );
let hash = Sha256::digest(canonical.as_bytes()); use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; Ok(URL_SAFE_NO_PAD.encode(hash))}
/// Store a DPoP key in the database with the private key encrypted.#[allow(clippy::too_many_arguments)]pub async fn store_dpop_key( pool: &sqlx::AnyPool, backend: DatabaseBackend, encryption_key: &[u8; 32], id: &str, provision_id: &str, api_client_id: &str, keypair: &DpopKeypair, pkce_challenge: Option<&str>,) -> Result<(), AppError> { let private_jwk_bytes = serde_json::to_vec(&keypair.private_jwk) .map_err(|e| AppError::Internal(format!("failed to serialize JWK: {e}")))?;
let encrypted = encrypt(encryption_key, &private_jwk_bytes) .map_err(|e| AppError::Internal(format!("failed to encrypt DPoP key: {e}")))?;
let now = now_rfc3339(); let sql = adapt_sql( "INSERT INTO dpop_keys (id, provision_id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, );
sqlx::query(&sql) .bind(id) .bind(provision_id) .bind(api_client_id) .bind(&encrypted) .bind(&keypair.thumbprint) .bind(pkce_challenge) .bind(&now) .execute(pool) .await .map_err(|e| AppError::Internal(format!("failed to store DPoP key: {e}")))?;
Ok(())}
/// Retrieve and decrypt a DPoP key by provision_id.pub async fn get_dpop_key( pool: &sqlx::AnyPool, backend: DatabaseBackend, encryption_key: &[u8; 32], provision_id: &str,) -> Result<(String, String, serde_json::Value, String, Option<String>), AppError> { // Returns: (id, api_client_id, private_jwk, thumbprint, pkce_challenge) let sql = adapt_sql( "SELECT id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge FROM dpop_keys WHERE provision_id = ?", backend, );
#[allow(clippy::type_complexity)] let row: Option<(String, String, Vec<u8>, String, Option<String>)> = sqlx::query_as(&sql) .bind(provision_id) .fetch_optional(pool) .await .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?;
let (id, api_client_id, encrypted, thumbprint, pkce_challenge) = row.ok_or_else(|| AppError::NotFound("DPoP key not found".into()))?;
let decrypted = decrypt(encryption_key, &encrypted) .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?;
let private_jwk: serde_json::Value = serde_json::from_slice(&decrypted) .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?;
Ok((id, api_client_id, private_jwk, thumbprint, pkce_challenge))}
/// Retrieve the JWK thumbprint for a DPoP key by its database ID.pub async fn get_dpop_key_thumbprint( pool: &sqlx::AnyPool, backend: DatabaseBackend, key_id: &str,) -> Result<String, AppError> { let sql = adapt_sql("SELECT jwk_thumbprint FROM dpop_keys WHERE id = ?", backend);
let row: Option<(String,)> = sqlx::query_as(&sql) .bind(key_id) .fetch_optional(pool) .await .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?;
row.map(|(t,)| t) .ok_or_else(|| AppError::NotFound("DPoP key not found".into()))}
/// Delete a DPoP key and its associated session.pub async fn delete_dpop_key( pool: &sqlx::AnyPool, backend: DatabaseBackend, dpop_key_id: &str,) -> Result<(), AppError> { // Session is deleted by CASCADE, but be explicit for clarity let session_sql = adapt_sql("DELETE FROM dpop_sessions WHERE dpop_key_id = ?", backend); let _ = sqlx::query(&session_sql) .bind(dpop_key_id) .execute(pool) .await;
let key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); sqlx::query(&key_sql) .bind(dpop_key_id) .execute(pool) .await .map_err(|e| AppError::Internal(format!("failed to delete DPoP key: {e}")))?;
Ok(())}
#[cfg(test)]mod tests { use super::*;
#[test] fn generate_keypair_produces_valid_jwk() { let keypair = generate_dpop_keypair().unwrap();
// Private JWK has d parameter assert!(keypair.private_jwk["d"].is_string()); assert_eq!(keypair.private_jwk["kty"], "EC"); assert_eq!(keypair.private_jwk["crv"], "P-256");
// Public JWK has no d parameter assert!(keypair.public_jwk["d"].is_null()); assert_eq!(keypair.public_jwk["kty"], "EC"); assert_eq!(keypair.public_jwk["crv"], "P-256");
// Thumbprint is a base64url string assert!(!keypair.thumbprint.is_empty()); assert!(!keypair.thumbprint.contains('=')); }
#[test] fn generate_keypair_produces_unique_keys() { let kp1 = generate_dpop_keypair().unwrap(); let kp2 = generate_dpop_keypair().unwrap(); assert_ne!(kp1.private_jwk["d"], kp2.private_jwk["d"]); assert_ne!(kp1.thumbprint, kp2.thumbprint); }
#[test] fn thumbprint_is_deterministic() { let keypair = generate_dpop_keypair().unwrap(); let t1 = compute_jwk_thumbprint(&keypair.public_jwk).unwrap(); let t2 = compute_jwk_thumbprint(&keypair.public_jwk).unwrap(); assert_eq!(t1, t2); assert_eq!(t1, keypair.thumbprint); }
#[test] fn thumbprint_differs_for_different_keys() { let kp1 = generate_dpop_keypair().unwrap(); let kp2 = generate_dpop_keypair().unwrap(); let t1 = compute_jwk_thumbprint(&kp1.public_jwk).unwrap(); let t2 = compute_jwk_thumbprint(&kp2.public_jwk).unwrap(); assert_ne!(t1, t2); }}