~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/atproto

atproto's primitives in Rust, small and free of I/O

0
0
main
159 lines · 5.3 KB Raw History
//! JOSE as atproto OAuth uses it: public keys as JWKs, their RFC 7638
//! thumbprints (a DPoP key's `jkt`), and compact JWTs signed with ES256 or
//! ES256K, the two curves atproto keys are on. What jose, which the
//! reference OAuth provider uses, makes of the same keys is in
//! `tests/reference/jose.json`.

use crate::keys::{Curve, Keypair, PublicKey};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JoseError(pub String);

impl fmt::Display for JoseError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for JoseError {}

fn err<T>(what: impl Into<String>) -> Result<T, JoseError> {
    Err(JoseError(what.into()))
}

/// `bytes`, base64url without padding.
pub fn b64(bytes: impl AsRef<[u8]>) -> String {
    URL_SAFE_NO_PAD.encode(bytes)
}

/// The bytes `text` holds, base64url without padding.
pub fn unb64(text: &str) -> Option<Vec<u8>> {
    URL_SAFE_NO_PAD.decode(text).ok()
}

/// The JSON object `part` holds, base64url: a JWT's header or claims.
pub fn json_of(part: &str) -> Option<Value> {
    let bytes = URL_SAFE_NO_PAD.decode(part).ok()?;
    serde_json::from_slice::<Value>(&bytes)
        .ok()
        .filter(Value::is_object)
}

/// SHA-256 of `text`, base64url: a PKCE challenge, a DPoP proof's `ath`.
pub fn sha256(text: &str) -> String {
    b64(Sha256::digest(text.as_bytes()))
}

/// `bytes` random bytes, base64url: a token, a nonce, a `jti`.
pub fn random(bytes: usize) -> String {
    let mut buf = vec![0; bytes];
    rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut buf);
    b64(buf)
}

fn crv(curve: Curve) -> &'static str {
    match curve {
        Curve::K256 => "secp256k1",
        Curve::P256 => "P-256",
    }
}

/// `key` as a public JWK.
pub fn jwk(key: &PublicKey) -> Value {
    let point = match key {
        PublicKey::K256(k) => k.to_encoded_point(false).as_bytes().to_vec(),
        PublicKey::P256(k) => k.to_encoded_point(false).as_bytes().to_vec(),
    };
    json!({
        "kty": "EC",
        "crv": crv(key.curve()),
        "x": b64(&point[1..33]),
        "y": b64(&point[33..]),
    })
}

/// The public key a JWK holds: an EC key on P-256 or secp256k1, never one
/// with its private part.
pub fn from_jwk(jwk: &Value) -> Result<PublicKey, JoseError> {
    if jwk.get("d").is_some() {
        return err("a JWK with its private key");
    }
    if jwk["kty"] != "EC" {
        return err("a JWK that is not an EC key");
    }
    let curve = match jwk["crv"].as_str() {
        Some("P-256") => Curve::P256,
        Some("secp256k1") => Curve::K256,
        other => return err(format!("a JWK on the curve {other:?}")),
    };
    let coordinate = |name: &str| {
        let text = jwk[name].as_str().unwrap_or_default();
        URL_SAFE_NO_PAD
            .decode(text)
            .ok()
            .filter(|c| c.len() == 32)
            .ok_or_else(|| JoseError(format!("a JWK whose {name} is not 32 bytes")))
    };
    let point = [&[4u8][..], &coordinate("x")?, &coordinate("y")?].concat();
    PublicKey::from_sec1(curve, &point).map_err(|e| JoseError(e.0.into()))
}

/// A JWK's RFC 7638 thumbprint, SHA-256: its required members, in order,
/// as JSON with no spaces.
pub fn thumbprint(key: &PublicKey) -> String {
    let jwk = jwk(key);
    let canonical = format!(
        r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#,
        jwk["crv"].as_str().unwrap_or_default(),
        jwk["x"].as_str().unwrap_or_default(),
        jwk["y"].as_str().unwrap_or_default(),
    );
    b64(Sha256::digest(canonical.as_bytes()))
}

/// `claims` signed with `key` as a compact JWT, under `header` with the
/// key's `alg` added.
pub fn sign(key: &Keypair, header: &Value, claims: &Value) -> String {
    let mut header = header.clone();
    header["alg"] = key.curve().alg().into();
    let body = format!("{}.{}", b64(header.to_string()), b64(claims.to_string()));
    format!("{body}.{}", b64(key.sign(body.as_bytes())))
}

/// A compact JWT's header and claims, unchecked: to find whose key to
/// check it with.
pub fn parts(token: &str) -> Result<(Value, Value), JoseError> {
    let pieces: Vec<&str> = token.split('.').collect();
    let [header, claims, _] = pieces[..] else {
        return err("not a compact JWT");
    };
    let decode = |part: &str, what: &str| {
        json_of(part).ok_or_else(|| JoseError(format!("bad JSON in a JWT {what}")))
    };
    Ok((decode(header, "header")?, decode(claims, "payload")?))
}

/// A JWT's header and claims, once it is signed by `key` with the key's
/// `alg`. Its claims' times are the caller's to check. Either `s` is
/// taken, as jose signs with either.
pub fn verify(token: &str, key: &PublicKey) -> Result<(Value, Value), JoseError> {
    let (header, claims) = parts(token)?;
    let alg = key.curve().alg();
    if header["alg"] != alg {
        return err(format!(
            "a JWT signed with {} where {alg} was expected",
            header["alg"]
        ));
    }
    let (signed, sig) = token.rsplit_once('.').unwrap_or_default();
    let sig = URL_SAFE_NO_PAD.decode(sig).unwrap_or_default();
    if !key.verify_either_s(signed.as_bytes(), &sig) {
        return err("bad signature");
    }
    Ok((header, claims))
}