Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[rust] Support providing tl_version + tl_headers via HTTP headers #280

Merged
merged 10 commits into from
May 24, 2024
12 changes: 6 additions & 6 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "truelayer-signing"
version = "0.2.1"
version = "0.3.0"
authors = ["Alex Butler <alex.butler@truelayer.com>"]
edition = "2021"
description = "Produce & verify TrueLayer API requests signatures"
Expand All @@ -10,10 +10,10 @@ license = "MIT OR Apache-2.0"
readme = "README.md"

[dependencies]
openssl = "0.10"
base64 = "0.22"
thiserror = "1.0"
anyhow = "1.0"
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
base64 = "0.22"
indexmap = "2.2"
openssl = "0.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
56 changes: 21 additions & 35 deletions rust/src/jws.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
use std::borrow::Cow;

use indexmap::IndexMap;

use crate::http::HeaderName;
use anyhow::anyhow;
use indexmap::{IndexMap, IndexSet};

/// `Tl-Signature` header.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct JwsHeader<'a> {
/// Algorithm, should be `ES512`.
pub alg: Cow<'a, str>,
/// Siging key id.
pub alg: JwsAlgorithm,
/// Signing key id.
pub kid: Cow<'a, str>,
/// Signing scheme version, e.g. `"2"`.
///
/// Empty implies v1, aka body-only signing.
#[serde(default)]
pub tl_version: Cow<'a, str>,
pub tl_version: Option<TlVersion>,
/// Comma separated ordered headers used in the signature.
#[serde(default)]
pub tl_headers: String,
pub tl_headers: Option<String>,
/// JSON Web Key URL. Used in webhook signatures providing the public key jwk url.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jku: Option<Cow<'a, str>>,
Expand All @@ -38,39 +38,25 @@ impl<'a> JwsHeader<'a> {
all
});
Self {
alg: Cow::Borrowed("ES512"),
alg: JwsAlgorithm::ES512,
kid: Cow::Borrowed(kid),
tl_version: Cow::Borrowed("2"),
tl_headers: header_keys,
tl_version: Some(TlVersion::V2),
tl_headers: Some(header_keys),
jku: jku.map(Cow::Borrowed),
}
}
}

/// Filter & order headers to match jws header `tl_headers`.
///
/// Returns an `Err(_)` if `headers` is missing any of the declared `tl_headers`.
pub(crate) fn filter_headers(
&'a self,
headers: &IndexMap<HeaderName<'_>, &'a [u8]>,
) -> anyhow::Result<IndexMap<HeaderName<'a>, &'a [u8]>> {
let required_headers: IndexSet<_> = self
.tl_headers
.split(',')
.filter(|h| !h.is_empty())
.map(HeaderName)
.collect();

// populate required headers in jws-header order
let ordered_headers: IndexMap<_, _> = required_headers
.iter()
.map(|h| {
let hval = headers
.get(h)
.ok_or_else(|| anyhow!("Missing tl_header `{}` declared in signature", h))?;
Ok((*h, *hval))
})
.collect::<anyhow::Result<_>>()?;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum TlVersion {
#[serde(rename = "1")]
V1,
#[serde(rename = "2")]
V2,
}

Ok(ordered_headers)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum JwsAlgorithm {
#[serde(rename = "ES512")]
ES512,
}
4 changes: 2 additions & 2 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ mod verify;

use common::Unset;
pub use http::Method;
pub use jws::JwsHeader;
pub use jws::{JwsAlgorithm, JwsHeader, TlVersion};
pub use sign::{CustomSigner, Signer, SignerBuilder};
use verify::PublicKey;
pub use verify::{CustomVerifier, Verifier, VerifierBuilder};
Expand Down Expand Up @@ -86,7 +86,7 @@ pub fn verify_with_jwks(jwks: &[u8]) -> VerifierBuilder<'_, PublicKey<'_>, Unset
///
/// This can then be used to pick a verification key using the `kid` etc.
pub fn extract_jws_header(tl_signature: &str) -> Result<JwsHeader, Error> {
Ok(verify::parse_tl_signature(tl_signature)?.0)
Ok(verify::parse_tl_signature(tl_signature)?.header)
}

/// Sign/verification error.
Expand Down
2 changes: 1 addition & 1 deletion rust/src/sign/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ impl<'a> SignerBuilder<'a, &'a str, &'a [u8], Unset, Unset, Unset> {

impl<'a, Pk, Body, Method, Path> SignerBuilder<'a, Unset, Pk, Body, Method, Path> {
/// Add the private key kid.
pub fn kid(self, kid: &str) -> SignerBuilder<'a, &str, Pk, Body, Method, Path> {
pub fn kid(self, kid: &'a str) -> SignerBuilder<'a, &'a str, Pk, Body, Method, Path> {
SignerBuilder {
kid,
private_key: self.private_key,
Expand Down
109 changes: 89 additions & 20 deletions rust/src/verify/custom_verifer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ use anyhow::anyhow;
use indexmap::{IndexMap, IndexSet};

use crate::{
base64::ToUrlSafeBase64, http::HeaderName, sign::build_v2_signing_payload, Error, JwsHeader,
base64::ToUrlSafeBase64, http::HeaderName, jws::TlVersion, sign::build_v2_signing_payload,
Error,
};

use super::parse_tl_signature;
use super::{parse_tl_signature, ParsedTlSignature};

/// A `Tl-Signature` Verifier for custom signature verification.
pub struct CustomVerifier<'a> {
Expand All @@ -16,7 +17,6 @@ pub struct CustomVerifier<'a> {
pub(crate) path: &'a str,
pub(crate) headers: IndexMap<HeaderName<'a>, &'a [u8]>,
pub(crate) required_headers: IndexSet<HeaderName<'a>>,
pub(crate) parsed_tl_sig: Option<(JwsHeader<'a>, &'a str, Vec<u8>)>,
}

/// Debug does not display key info.
Expand All @@ -28,29 +28,66 @@ impl fmt::Debug for CustomVerifier<'_> {

impl<'a> CustomVerifier<'a> {
pub fn verify_with(
mut self,
self,
tl_signature: &'a str,
verify_fn: impl FnMut(&[u8], &[u8]) -> Result<(), Error>,
) -> Result<(), Error> {
let parsed_tl_signature = parse_tl_signature(tl_signature)?;
self.verify_parsed_with(parsed_tl_signature, verify_fn)
}

pub(crate) fn verify_parsed_with(
self,
tl_signature: ParsedTlSignature<'a>,
mut verify_fn: impl FnMut(&[u8], &[u8]) -> Result<(), Error>,
) -> Result<(), Error> {
let (jws_header, header_b64, signature) = unsafe {
if self.parsed_tl_sig.is_none() {
self.parsed_tl_sig = Some(parse_tl_signature(tl_signature)?);
}
self.parsed_tl_sig.unwrap_unchecked()
};

if jws_header.alg != "ES512" {
return Err(Error::JwsError(anyhow!("unexpected header alg")));
let ParsedTlSignature {
header: jws_header,
header_b64,
signature,
} = tl_signature;

let mut required_headers = self.required_headers.clone();

let version = jws_header
.tl_version
.map(Ok)
.or_else(|| {
let version_header_name = HeaderName("Tl-Signature-Version");
required_headers.insert(version_header_name);
self.get_header_string_value_safe(&version_header_name)
.map(|v| match v.as_str() {
"2" => Ok(TlVersion::V2),
v => Err(Error::JwsError(anyhow!(
"unexpected header tl_version: {}",
v
))),
})
})
.ok_or(Error::JwsError(anyhow!("missing header tl_version")))??;

if version != TlVersion::V2 {
return Err(Error::JwsError(anyhow!(
"expected header tl_version v2 found {:?}",
version
)));
}

// check and order all required headers
let ordered_headers = jws_header
.filter_headers(&self.headers)
let included_header_names_csv = jws_header
.tl_headers
.or_else(|| {
let headers_header_name = HeaderName("Tl-Signature-Headers");
required_headers.insert(headers_header_name);
self.get_header_string_value_safe(&headers_header_name)
})
.ok_or_else(|| Error::JwsError(anyhow!("missing header tl_headers")))?;
// check and order all included headers
let ordered_headers = &self
.get_included_headers(&included_header_names_csv)
.map_err(Error::JwsError)?;

// fail if signature is missing a required header
if let Some(header) = self
.required_headers
if let Some(header) = required_headers
.iter()
.find(|h| !ordered_headers.contains_key(*h))
{
Expand All @@ -62,7 +99,7 @@ impl<'a> CustomVerifier<'a> {

// reconstruct the payload as it would have been signed
let signing_payload =
build_v2_signing_payload(self.method, self.path, &ordered_headers, self.body, false);
build_v2_signing_payload(self.method, self.path, ordered_headers, self.body, false);
let payload = format!("{header_b64}.{}", signing_payload.to_url_safe_base64());

verify_fn(payload.as_bytes(), signature.as_slice()).or_else(|e| {
Expand All @@ -72,10 +109,42 @@ impl<'a> CustomVerifier<'a> {
p => (p, true),
};
let signing_payload =
build_v2_signing_payload(self.method, path, &ordered_headers, self.body, slash);
build_v2_signing_payload(self.method, path, ordered_headers, self.body, slash);
let payload = format!("{header_b64}.{}", signing_payload.to_url_safe_base64());
// use original error if both fail
verify_fn(payload.as_bytes(), signature.as_slice()).map_err(|_| e)
})
}

fn get_included_headers(
&self,
included_header_names_csv: &'a str,
) -> anyhow::Result<IndexMap<HeaderName<'a>, &'a [u8]>> {
let included_header_names: IndexSet<_> = included_header_names_csv
.split(',')
.filter(|h| !h.is_empty())
.map(HeaderName)
.collect();

// populate included headers in specified order
let ordered_headers: IndexMap<_, _> = included_header_names
.iter()
.map(|h| {
let hval = self
.headers
.get(h)
.ok_or_else(|| anyhow!("Missing tl_header `{}` declared in signature", h))?;
Ok((*h, *hval))
})
.collect::<anyhow::Result<_>>()?;

Ok(ordered_headers)
}

fn get_header_string_value_safe(&self, header_name: &HeaderName) -> Option<String> {
self.headers
.get(header_name)
.and_then(|h| std::str::from_utf8(h).ok())
.map(|s| s.to_string())
}
}
Loading
Loading