use std::time::Duration;
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use anyhow::Context as _;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use knot_runtime::{Entropy as _, OsEntropy};
use knot_types::lowercase_hex;
use p256::ecdsa::signature::Signer;
use p256::ecdsa::{Signature, SigningKey};
use p256::elliptic_curve::rand_core::OsRng;
use sha2::{Digest, Sha256};
use super::client_id;
use crate::config::ValidatedConfig;
pub(crate) fn now_secs() -> i64 {
crate::atproto::unix_now().get()
}
pub(crate) fn random_hex(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
OsEntropy.fill(&mut buf);
lowercase_hex(&buf)
}
pub(crate) fn http_client() -> anyhow::Result<reqwest::Client> {
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(30));
if let Some(path) = std::env::var_os("KNOT_EXTRA_CA_FILE") {
let pem = std::fs::read(&path).context("read KNOT_EXTRA_CA_FILE")?;
for cert in reqwest::Certificate::from_pem_bundle(&pem)? {
builder = builder.add_root_certificate(cert);
}
}
Ok(builder.build()?)
}
pub(crate) fn b64u_encode(data: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(data)
}
pub(crate) fn derive_gcm_key(config: &ValidatedConfig) -> anyhow::Result<Aes256Gcm> {
let master_key = crate::identity::load_master_key(config)?;
let key_hash = Sha256::digest(master_key.as_bytes());
Aes256Gcm::new_from_slice(&key_hash).context("derive aes-gcm key")
}
pub(crate) fn seal_bytes(cipher: &Aes256Gcm, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
let mut nonce = [0u8; 12];
OsEntropy.fill(&mut nonce);
let ciphertext = cipher
.encrypt(&nonce.into(), plaintext)
.map_err(|e| anyhow::anyhow!("aes-gcm encryption error: {e}"))?;
let mut out = Vec::with_capacity(12 + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Ok(out)
}
pub(crate) fn unseal_bytes(cipher: &Aes256Gcm, sealed: &[u8]) -> anyhow::Result<Vec<u8>> {
anyhow::ensure!(sealed.len() > 12, "sealed payload too short");
let (nonce_bytes, ciphertext) = sealed.split_at(12);
let nonce_array: [u8; 12] = nonce_bytes.try_into()?;
let nonce = Nonce::from(nonce_array);
cipher
.decrypt(&nonce, ciphertext)
.map_err(|e| anyhow::anyhow!("aes-gcm decryption error: {e}"))
}
pub(crate) struct KeyPair {
signing_key: SigningKey,
}
impl KeyPair {
pub(crate) fn generate() -> Self {
Self {
signing_key: SigningKey::random(&mut OsRng),
}
}
pub(crate) fn from_bytes(scalar: &[u8]) -> anyhow::Result<Self> {
let signing_key = SigningKey::from_bytes(scalar.into()).context("invalid P-256 scalar")?;
Ok(Self { signing_key })
}
pub(crate) fn to_bytes(&self) -> Vec<u8> {
self.signing_key.to_bytes().to_vec()
}
pub(super) fn jwk(&self, kid: Option<&str>) -> serde_json::Value {
let pt = self.signing_key.verifying_key().to_encoded_point(false);
let x = b64u_encode(&pt.as_bytes()[1..33]);
let y = b64u_encode(&pt.as_bytes()[33..65]);
let mut map = serde_json::json!({
"kty": "EC",
"crv": "P-256",
"x": x,
"y": y,
});
if let Some(kid) = kid {
map["kid"] = serde_json::json!(kid);
map["use"] = serde_json::json!("sig");
map["alg"] = serde_json::json!("ES256");
}
map
}
fn sign_jws(
&self,
header: serde_json::Value,
payload: serde_json::Value,
) -> anyhow::Result<String> {
let h_b64 = b64u_encode(&serde_json::to_vec(&header)?);
let p_b64 = b64u_encode(&serde_json::to_vec(&payload)?);
let signing_input = format!("{h_b64}.{p_b64}");
let sig: Signature = self.signing_key.sign(signing_input.as_bytes());
let s_b64 = b64u_encode(&sig.to_bytes());
Ok(format!("{signing_input}.{s_b64}"))
}
}
pub(crate) fn client_assertion(
client_key: &KeyPair,
config: &ValidatedConfig,
audience: &str,
) -> anyhow::Result<String> {
let cid = client_id(config);
let now = now_secs();
let header = serde_json::json!({
"alg": "ES256",
"typ": "JWT",
"kid": "knot-static-client",
});
let payload = serde_json::json!({
"iss": cid,
"sub": cid,
"aud": audience,
"jti": random_hex(16),
"iat": now,
"exp": now + 300,
});
client_key.sign_jws(header, payload)
}
pub(crate) fn dpop_proof(
dpop_key: &KeyPair,
htm: &str,
htu: &str,
nonce: Option<&str>,
access_token: Option<&str>,
) -> anyhow::Result<String> {
let now = now_secs();
let htu_clean = htu.split('?').next().unwrap_or(htu);
let header = serde_json::json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": dpop_key.jwk(None),
});
let mut payload = serde_json::json!({
"jti": random_hex(16),
"htm": htm.to_uppercase(),
"htu": htu_clean,
"iat": now,
});
if let Some(nonce) = nonce {
payload["nonce"] = serde_json::json!(nonce);
}
if let Some(token) = access_token {
let digest = Sha256::digest(token.as_bytes());
payload["ath"] = serde_json::json!(b64u_encode(&digest));
}
dpop_key.sign_jws(header, payload)
}
pub(crate) fn dpop_nonce_retry(
resp: &reqwest::Response,
current_nonce: &mut Option<String>,
) -> bool {
let status = resp.status().as_u16();
if (status == 400 || status == 401)
&& let Some(new_nonce) = resp
.headers()
.get("DPoP-Nonce")
.and_then(|h| h.to_str().ok())
&& current_nonce.as_deref() != Some(new_nonce)
{
*current_nonce = Some(new_nonce.to_string());
return true;
}
false
}