open-standards/openpay/crates/op-openbanking/src/fapi.rs
Transaction Science 6ea90afbed ingest wave A4: 3 crates · smart-byte-atproto + op-openbanking + eoc-distributed
Smart Byte (1 crate, 2127 LOC, 35 tests)
  smart-byte-atproto    ATProto (Bluesky federation) adapter.
                        PLC + did:web resolution; XRPC client; CARv1 encode/decode
                        with CIDv1 (dag-cbor + raw, SHA-256); sketch MST with
                        order-independent BLAKE3 root; Repo + UnsignedCommit /
                        SignedCommit (Ed25519 over CBOR); firehose transport
                        trait + in-memory impl; AT URI ↔ smart-byte AID bridge
                        with SAID commitment.

OpenPay (1 crate, 2397 LOC, 44 tests)
  op-openbanking        UK OBIE R/W v3.1.10/3.1.11, Berlin Group NextGenPSD2
                        XS2A v1.3.13, STET PSD2 v1.7, Australia CDR Banking v1.31,
                        FDX v5/v6, SGFinDex shapes. AISP / PISP / PIIS / VRP
                        traits. FAPI 1.0 Advanced (PS256/ES256, mTLS, RFC 8705
                        certificate binding, JWK registration). Operators wire
                        their own JWS signer + HTTP transport — keeps the crate
                        KMS-agnostic.

EOC (1 crate, 2179 LOC, 32 tests)
  eoc-distributed       Distributed-inference scheduling. Worker trait with
                        Capability + Accelerator (CPU/GPU/TPU/NPU/Mixed) +
                        Load.busyness()/projected_micro_joules()/projected_g_co2e().
                        RoundRobin / LeastBusy / LatencyWeighted / JouleWeighted
                        (default) / CarbonWeighted routers. Work-stealing
                        scheduler. vLLM-style continuous batching with KV-block
                        budget. Ray-Serve-style auto-scaler with cooldown.
                        KV-cache-aware sticky-session routing.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 12:22:09 -04:00

254 lines
9.5 KiB
Rust

//! FAPI 1.0 Advanced security profile interfaces.
//!
//! [FAPI](https://openid.net/specs/openid-financial-api-part-2-1_0.html) is the
//! OpenID Foundation's profile of OAuth 2.0 that the major Open Banking
//! regimes mandate. It pins:
//!
//! - mTLS for client authentication (RFC 8705), with certificate-bound
//! access tokens (`cnf.x5t#S256`).
//! - JWS request objects (RFC 9101, formerly RFC 7515 / FAPI § 5.2.2)
//! signed with `PS256` or `ES256`. `RS256` is forbidden in
//! Advanced; `none` is forbidden everywhere.
//! - `s_hash` and `c_hash` covering the `state` and `code` values to
//! bind the authorization-code grant.
//! - `x-fapi-interaction-id` request/response header for forensic
//! traceability.
//!
//! This module exposes the *interfaces*, not implementations. Operators
//! plug in a KMS / HSM / eIDAS QSealC card behind [`JwsSigner`] and an
//! mTLS-terminating proxy behind [`MtlsClientCert`].
use serde::{Deserialize, Serialize};
use crate::error::Result;
/// FAPI profile level the operator has deployed.
///
/// The crate is wired for Advanced everywhere; Baseline is recognised
/// for legacy ASPSPs that have not yet migrated. Some ASPSPs (UK
/// CMA9 banks) require Advanced unconditionally.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FapiProfile {
/// FAPI 1.0 Baseline. Read-only AISP-style flows only.
Baseline,
/// FAPI 1.0 Advanced. Mandatory for PISP, VRP, and CBPII.
Advanced,
}
/// Approved JWS algorithms per FAPI 1.0 Advanced § 8.6.
///
/// `RS256` is explicitly *forbidden* in Advanced (CVE-2018-0114 family
/// of attacks against pre-PSS RSA signatures), so we do not enumerate
/// it here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum JwsAlgorithm {
/// RSASSA-PSS using SHA-256 and MGF1 with SHA-256.
Ps256,
/// ECDSA using P-256 and SHA-256.
Es256,
}
impl JwsAlgorithm {
/// IANA-registered string used in the JWS `alg` header parameter.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ps256 => "PS256",
Self::Es256 => "ES256",
}
}
}
/// SHA-256 JWK thumbprint per RFC 7638. Twenty bytes are not enough;
/// FAPI binds tokens to the full 32-byte digest, base64url-encoded
/// in `cnf.x5t#S256`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JwkThumbprint(pub [u8; 32]);
/// A request object as carried in the FAPI `request` parameter.
///
/// The crate constructs the canonical payload (claims + protected
/// header) and hands it to [`JwsSigner::sign`]. The signer returns
/// the compact JWS string the operator sends on the wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RequestObject {
/// JOSE protected header in JSON form.
pub protected_header: String,
/// Canonical claims payload in JSON form.
pub payload: String,
/// The algorithm the signer must use.
pub alg: JwsAlgorithm,
/// Key ID (`kid`) the signer should use, if multiple keys are
/// registered with the ASPSP / OIDF directory.
pub kid: Option<String>,
}
/// Detached JWS signature value (the trailing `..signature` form
/// FAPI uses for `x-jws-signature` over the HTTP body) or compact
/// JWS string (`header.payload.signature` for request objects).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JwsSignature(pub String);
/// Trait implemented by operators to provide cryptographic signing
/// without exposing key material to the crate.
///
/// **No default impl is provided.** Operators must wire a KMS, HSM,
/// eIDAS QSealC card, or OBSeal directory entry. The crate refuses
/// to ship a soft-key signer because a leaked operator key on a
/// regulated rail is a regulatory incident, not a bug.
pub trait JwsSigner: Send + Sync {
/// Sign a request object.
fn sign(&self, request: &RequestObject) -> Result<JwsSignature>;
/// Sign an arbitrary HTTP body for the FAPI `x-jws-signature`
/// detached-payload header.
fn sign_detached(&self, body: &[u8], alg: JwsAlgorithm) -> Result<JwsSignature>;
/// SHA-256 thumbprint of the public key this signer is bound to.
fn key_thumbprint(&self) -> Result<JwkThumbprint>;
}
/// Trait implemented by operators to surface the mTLS client
/// certificate that terminates inbound on the ASPSP side.
///
/// Most production deployments terminate mTLS in a proxy (Envoy,
/// nginx, CloudHSM-fronted broker). The proxy then passes the
/// client cert thumbprint down to the application via an
/// HTTP header (`X-SSL-Client-SHA256`, similar). This trait
/// abstracts that path.
pub trait MtlsClientCert: Send + Sync {
/// SHA-256 thumbprint of the client certificate the operator
/// presented to the ASPSP. Used to verify `cnf.x5t#S256`
/// binding on the resulting access token (RFC 8705 § 3).
fn cert_thumbprint(&self) -> Result<JwkThumbprint>;
/// Subject DN of the client certificate, as required by some
/// ASPSPs for additional binding checks (Berlin Group XS2A
/// passes the subject DN in `TPP-Signature-Certificate`).
fn subject_dn(&self) -> Result<String>;
}
/// Trait implemented by operators to register JWKs with an ASPSP or
/// open-banking directory (OBIE, eIDAS QTSP, Open Banking Brasil
/// directory).
///
/// JWK registration is a side-channel: the operator publishes their
/// signing public keys to a directory, the ASPSP fetches them at
/// connection-establishment time, then accepts JWS request objects
/// signed by those keys. The trait is intentionally narrow.
pub trait JwkRegistration: Send + Sync {
/// Look up a JWK by thumbprint. Returns the JWK as a JSON string
/// in RFC 7517 form. `None` if not registered.
fn lookup(&self, thumbprint: &JwkThumbprint) -> Result<Option<String>>;
}
/// An OAuth 2.0 token as returned by an ASPSP authorization server.
///
/// In FAPI deployments, `access_token.cnf.x5t#S256` is bound to the
/// mTLS client certificate. Refresh tokens are bound the same way.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OAuth2Token {
/// Opaque or JWT-formatted access token.
pub access_token: String,
/// Token type. Almost always `Bearer` in FAPI; the cert-binding
/// is enforced by the resource server, not by the token type.
pub token_type: String,
/// Scopes granted (may be narrower than what was requested).
pub scopes: Vec<String>,
/// Seconds until expiry, as advertised by the AS at issuance.
pub expires_in: u64,
/// Optional refresh token. UK OBIE long-lived consents emit one;
/// Berlin Group typically does not.
pub refresh_token: Option<String>,
/// Certificate thumbprint the token is bound to, when the AS
/// supports RFC 8705 § 3. `None` when binding is not in use.
pub cert_thumbprint: Option<JwkThumbprint>,
}
impl OAuth2Token {
/// Verify the token's certificate binding against the live
/// mTLS client certificate.
///
/// Returns [`crate::Error::CertificateBindingMismatch`] when the
/// token carries a `cnf.x5t#S256` that does not match the
/// certificate the operator is currently presenting.
///
/// Tokens that do not carry a binding (`cert_thumbprint == None`)
/// pass through. That's a deployment choice, not a bug — some
/// sandbox ASPSPs accept bearer tokens without binding.
pub fn verify_binding(&self, mtls: &dyn MtlsClientCert) -> Result<()> {
if let Some(bound) = &self.cert_thumbprint {
let live = mtls.cert_thumbprint()?;
if bound != &live {
return Err(crate::Error::CertificateBindingMismatch);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FixedCert(JwkThumbprint);
impl MtlsClientCert for FixedCert {
fn cert_thumbprint(&self) -> Result<JwkThumbprint> {
Ok(self.0.clone())
}
fn subject_dn(&self) -> Result<String> {
Ok("CN=test-tpp,O=OpenPay,C=GB".into())
}
}
#[test]
fn binding_matches() {
let tp = JwkThumbprint([7u8; 32]);
let token = OAuth2Token {
access_token: "x".into(),
token_type: "Bearer".into(),
scopes: vec!["accounts".into()],
expires_in: 3600,
refresh_token: None,
cert_thumbprint: Some(tp.clone()),
};
let cert = FixedCert(tp);
token.verify_binding(&cert).expect("matches");
}
#[test]
fn binding_mismatch_rejected() {
let token = OAuth2Token {
access_token: "x".into(),
token_type: "Bearer".into(),
scopes: vec!["accounts".into()],
expires_in: 3600,
refresh_token: None,
cert_thumbprint: Some(JwkThumbprint([1u8; 32])),
};
let cert = FixedCert(JwkThumbprint([2u8; 32]));
let err = token.verify_binding(&cert).expect_err("mismatch");
assert!(matches!(err, crate::Error::CertificateBindingMismatch));
}
#[test]
fn alg_names_match_iana() {
assert_eq!(JwsAlgorithm::Ps256.as_str(), "PS256");
assert_eq!(JwsAlgorithm::Es256.as_str(), "ES256");
}
#[test]
fn unbound_token_passes_through() {
let token = OAuth2Token {
access_token: "x".into(),
token_type: "Bearer".into(),
scopes: vec!["accounts".into()],
expires_in: 3600,
refresh_token: None,
cert_thumbprint: None,
};
let cert = FixedCert(JwkThumbprint([0u8; 32]));
token.verify_binding(&cert).expect("unbound ok");
}
}