Skip to content

Commit

Permalink
[rust] Support providing tl_version + tl_headers via HTTP headers (#280)
Browse files Browse the repository at this point in the history
* [rust] Add failing test

* [rust] Make tl_version + tl_headers optional

* Move included header name parsing to CustomVerifier

* Fallback to Tl-Signature-Headers and Tl-Signature-Version

* refactor to use concrete types

* revert use of uuid

* improve error msg

* Bump version

* Update rust/Cargo.toml

Co-authored-by: tl-flavio-barinas <95243153+tl-flavio-barinas@users.noreply.github.com>

---------

Co-authored-by: tl-flavio-barinas <flavio.barinas@truelayer.com>
Co-authored-by: tl-flavio-barinas <95243153+tl-flavio-barinas@users.noreply.github.com>
  • Loading branch information
3 people committed May 24, 2024
1 parent 9a874e7 commit 132c620
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 113 deletions.
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

0 comments on commit 132c620

Please sign in to comment.