dpp-crypto 0.21.0

Ed25519 key management, JWS signing/verification, JAdES baseline construction, and an encrypted keystore
Documentation
//! JWS compact serialisation signer — EdDSA over RFC 8785 (JCS) canonical bytes.

use base64::Engine;
use ed25519_dalek::Signer;
use serde_json::Value;

use crate::keystore::KeyStore;

/// Produces a compact JWS with algorithm `EdDSA` over the **RFC 8785 (JCS)
/// canonical** form of the JSON payload.
///
/// Signing over the canonical bytes (rather than incidental serde output) is
/// the content-binding contract: a verifier recomputes the canonical form of
/// the payload it holds and compares — see [`super::canonical`] and
/// `dpp_vc::local_service`.
///
/// The protected header includes a `kid` field set to the SHA-256 fingerprint
/// (hex) of the signing key's public bytes.  The verifier uses this `kid` to
/// select the correct verification method from the DID document, so that JWS
/// signatures remain verifiable after the operator rotates their key.
///
/// The curve is *not* emitted as a JOSE header parameter: RFC 8037 defines
/// `crv` as a JWK member, not a registered header parameter. It lives on the
/// DID document's `publicKeyJwk` instead, where it is the spec-correct place.
pub fn sign(store: &KeyStore, key_id: &str, payload: &Value) -> anyhow::Result<String> {
    sign_typed(store, key_id, payload, None)
}

/// [`sign`], with an optional `typ` protected-header parameter.
///
/// `typ` exists because some JWS profiles require the token to declare what it
/// is — SD-JWT VC is one, and mandates `dc+sd-jwt`. It is threaded through here
/// rather than bolted on afterwards because the header is *protected*: adding a
/// parameter after signing would invalidate the signature, so the only place it
/// can be set is before the signing input is built.
///
/// `None` produces exactly the header [`sign`] has always produced, so existing
/// signatures and their verifiers are unaffected.
pub fn sign_typed(
    store: &KeyStore,
    key_id: &str,
    payload: &Value,
    typ: Option<&str>,
) -> anyhow::Result<String> {
    let key = store.load_key(key_id)?;
    // Serialised rather than interpolated. `typ` is caller-supplied, and a value
    // containing a quote or a backslash would otherwise escape the string it
    // sits in — producing malformed JSON at best, and at worst letting a caller
    // write additional members into a header that is about to be *signed*.
    //
    // The byte output is unchanged for `typ: None`: `serde_json::Map` is a
    // `BTreeMap` here (no `preserve_order` feature in this workspace), so
    // members serialise in lexicographic order, and `alg` < `kid` < `typ` is
    // the order the hand-written literal already used. Existing signatures and
    // the verifiers that check them are unaffected.
    let mut header = serde_json::Map::new();
    header.insert(
        "alg".to_owned(),
        Value::String(key.algorithm.jose_alg().to_owned()),
    );
    header.insert("kid".to_owned(), Value::String(key.fingerprint.clone()));
    if let Some(typ) = typ {
        header.insert("typ".to_owned(), Value::String(typ.to_owned()));
    }
    let header_json = serde_json::to_string(&header)?;
    let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
    let canonical = super::canonical::canonicalize(payload)?;
    let header_b64 = b64.encode(header_json.as_bytes());
    let payload_b64 = b64.encode(&canonical);

    let signing_input = format!("{header_b64}.{payload_b64}");
    let signature = key.signing_key.sign(signing_input.as_bytes());
    let sig_b64 = b64.encode(signature.to_bytes());

    Ok(format!("{signing_input}.{sig_b64}"))
}

/// Verify a compact JWS produced by [`sign`] using the key store.
pub fn verify(store: &KeyStore, key_id: &str, jws: &str) -> anyhow::Result<bool> {
    let parts: Vec<&str> = jws.splitn(3, '.').collect();
    if parts.len() != 3 {
        return Ok(false);
    }

    let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;

    let key = store.load_key(key_id)?;

    // Bind `alg` to the key record, never the other way round. The header is
    // attacker-supplied, so it may only *confirm* what the key already says —
    // it must never select the verification path. Rejects `alg:none` and every
    // substitution by the same check.
    if !super::verifier::header_alg_matches(&b64, parts[0], key.algorithm) {
        return Ok(false);
    }

    let signing_input = format!("{}.{}", parts[0], parts[1]);

    let sig_bytes = b64
        .decode(parts[2])
        .map_err(|e| anyhow::anyhow!("base64: {e}"))?;

    let sig_arr: [u8; 64] = sig_bytes
        .as_slice()
        .try_into()
        .map_err(|_| anyhow::anyhow!("invalid signature length"))?;

    let signature = ed25519_dalek::Signature::from_bytes(&sig_arr);

    // Strict verification (RFC 8032 §8) — see verifier::verify_jws.
    Ok(key
        .verifying_key
        .verify_strict(signing_input.as_bytes(), &signature)
        .is_ok())
}