From 904d429ebbbdd81a897e497748a501a36a819b7d Mon Sep 17 00:00:00 2001 From: tl-flavio-barinas <95243153+tl-flavio-barinas@users.noreply.github.com> Date: Wed, 6 Mar 2024 10:02:19 +0000 Subject: [PATCH] rust: adds `sign_with` function (#249) * rust: adds sign with function * small cleanup * refactor to use custom signer struct * prerelease alpha.1 * big refactor * Update rust/CHANGELOG.md --------- Co-authored-by: Kevin Plattret Co-authored-by: Marco Tormento <91872926+tl-marco-tormento@users.noreply.github.com> --- rust/CHANGELOG.md | 14 ++ rust/Cargo.toml | 16 +- rust/src/base64.rs | 6 +- rust/src/common.rs | 1 + rust/src/http.rs | 22 ++ rust/src/jws.rs | 29 +-- rust/src/lib.rs | 51 ++--- rust/src/openssl.rs | 5 +- rust/src/sign.rs | 237 --------------------- rust/src/sign/custom_signer.rs | 69 ++++++ rust/src/sign/mod.rs | 338 ++++++++++++++++++++++++++++++ rust/src/sign/signer_v1.rs | 49 +++++ rust/src/verify.rs | 220 ------------------- rust/src/verify/custom_verifer.rs | 81 +++++++ rust/src/verify/mod.rs | 309 +++++++++++++++++++++++++++ rust/src/verify/verifier_v1.rs | 51 +++++ rust/tests/usage.rs | 124 ++++++----- 17 files changed, 1064 insertions(+), 558 deletions(-) create mode 100644 rust/src/common.rs delete mode 100644 rust/src/sign.rs create mode 100644 rust/src/sign/custom_signer.rs create mode 100644 rust/src/sign/mod.rs create mode 100644 rust/src/sign/signer_v1.rs delete mode 100644 rust/src/verify.rs create mode 100644 rust/src/verify/custom_verifer.rs create mode 100644 rust/src/verify/mod.rs create mode 100644 rust/src/verify/verifier_v1.rs diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index adb6c50e..aa519dd2 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.2.0 +* Introduces the Http `Method` enum to replace string literals for HTTP methods, enhancing type safety, code clarity, and a more robust and developer-friendly API. +* The `Signer` has become the `SignerBuilder`: + - uses generics for compile time correctness checks + - requires an explicit build call + - `build_custom_signer` requires the kid, method, body(optional of GET requests), and path to be set. It builds a `CustomSigner` which exposes a `sign_with` function. + - `build_v1_signer` requires private key, kid, and body to be set. It builds a `SignerV1` and exposes a `sign_body_only` function. + - `build_signer` requires the private key, kid, body, method, and path to be set. It builds a `Signer` and exposes a `sign` function. +* The `Verifier` has become the `VerifierBuilder`, which uses generics for compile time correctness checks. + - uses generics for compile time correctness checks + - requires an explicit build call + - `build_v1_verifier` requires the public key and body to be set. It builds a `VerifierV1` and exposes a `verify_body_only` function. + - `build_verifier` requires the public key, body, method, and path to be set. It builds a `Verifier` and exposes `verify` and `verify_v1_or_v2` functions. + ## 0.1.5 * Improves error handling when parsing and invalid signature. diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 377806d6..97c306f7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "truelayer-signing" -version = "0.1.6" +version = "0.2.0" authors = ["Alex Butler "] edition = "2021" description = "Produce & verify TrueLayer API requests signatures" @@ -10,10 +10,10 @@ license = "MIT OR Apache-2.0" readme = "README.md" [dependencies] -openssl = "0.10.38" -base64 = "0.13" -thiserror = "1.0.30" -anyhow = "1.0.44" -serde_json = "1.0.68" -serde = { version = "1.0.130", features = ["derive"] } -indexmap = "1.7.0" +openssl = "0.10" +base64 = "0.21" +thiserror = "1.0" +anyhow = "1.0" +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +indexmap = "2.2" diff --git a/rust/src/base64.rs b/rust/src/base64.rs index f51920f4..28b20c66 100644 --- a/rust/src/base64.rs +++ b/rust/src/base64.rs @@ -1,3 +1,5 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + pub(crate) trait ToUrlSafeBase64 { fn to_url_safe_base64(&self) -> String; } @@ -7,7 +9,7 @@ where { #[inline] fn to_url_safe_base64(&self) -> String { - base64::encode_config(self, base64::URL_SAFE_NO_PAD) + URL_SAFE_NO_PAD.encode(self) } } pub(crate) trait DecodeUrlSafeBase64 { @@ -19,6 +21,6 @@ where { #[inline] fn decode_url_safe_base64(&self) -> Result, base64::DecodeError> { - base64::decode_config(self, base64::URL_SAFE_NO_PAD) + URL_SAFE_NO_PAD.decode(self) } } diff --git a/rust/src/common.rs b/rust/src/common.rs new file mode 100644 index 00000000..1e5f97ce --- /dev/null +++ b/rust/src/common.rs @@ -0,0 +1 @@ +pub struct Unset; diff --git a/rust/src/http.rs b/rust/src/http.rs index e7c8388d..69f6b7fd 100644 --- a/rust/src/http.rs +++ b/rust/src/http.rs @@ -3,6 +3,28 @@ use std::{ hash::{Hash, Hasher}, }; +/// A valid HTTP method +#[derive(Debug, Clone, Copy)] +pub enum Method { + Get, + Post, +} + +impl Method { + pub const fn name(self) -> &'static str { + match self { + Method::Get => "GET", + Method::Post => "POST", + } + } +} + +impl std::fmt::Display for Method { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name()) + } +} + /// A case-sensitive header name, with case-insensitive /// `Eq` & `Hash` implementations. #[derive(Clone, Copy, Eq)] diff --git a/rust/src/jws.rs b/rust/src/jws.rs index e5a4ed93..6e830b84 100644 --- a/rust/src/jws.rs +++ b/rust/src/jws.rs @@ -1,33 +1,34 @@ +use std::borrow::Cow; + use crate::http::HeaderName; use anyhow::anyhow; use indexmap::{IndexMap, IndexSet}; /// `Tl-Signature` header. #[derive(Debug, serde::Serialize, serde::Deserialize)] -#[non_exhaustive] -pub struct JwsHeader { +pub struct JwsHeader<'a> { /// Algorithm, should be `ES512`. - pub alg: String, + pub alg: Cow<'a, str>, /// Siging key id. - pub kid: String, + pub kid: Cow<'a, str>, /// Signing scheme version, e.g. `"2"`. /// /// Empty implies v1, aka body-only signing. #[serde(default)] - pub tl_version: String, + pub tl_version: Cow<'a, str>, /// Comma separated ordered headers used in the signature. #[serde(default)] pub tl_headers: 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, + pub jku: Option>, } -impl JwsHeader { +impl<'a> JwsHeader<'a> { pub(crate) fn new_v2( - kid: &str, + kid: &'a str, headers: &IndexMap, &[u8]>, - jku: Option, + jku: Option<&'a str>, ) -> Self { let header_keys = headers.keys().fold(String::new(), |mut all, next| { if !all.is_empty() { @@ -37,18 +38,18 @@ impl JwsHeader { all }); Self { - alg: "ES512".into(), - kid: kid.into(), - tl_version: "2".into(), + alg: Cow::Borrowed("ES512"), + kid: Cow::Borrowed(kid), + tl_version: Cow::Borrowed("2"), tl_headers: header_keys, - jku, + 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>( + pub(crate) fn filter_headers( &'a self, headers: &IndexMap, &'a [u8]>, ) -> anyhow::Result, &'a [u8]>> { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 63b10658..c2bdfaf4 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,28 +1,18 @@ //! Produce & verify TrueLayer API `Tl-Signature` request headers. -//! -//! # Example -//! ```no_run -//! # fn main() -> Result<(), truelayer_signing::Error> { -//! # let (kid, private_key, idempotency_key, body) = unimplemented!(); -//! // `Tl-Signature` value to send with the request. -//! let tl_signature = truelayer_signing::sign_with_pem(kid, private_key) -//! .method("POST") -//! .path("/payouts") -//! .header("Idempotency-Key", idempotency_key) -//! .body(body) -//! .sign()?; -//! # Ok(()) } -//! ``` mod base64; +mod common; mod http; mod jws; mod openssl; mod sign; mod verify; +use common::Unset; +pub use http::Method; pub use jws::JwsHeader; -pub use sign::Signer; -pub use verify::Verifier; +pub use sign::{CustomSigner, Signer, SignerBuilder}; +use verify::PublicKey; +pub use verify::{CustomVerifier, Verifier, VerifierBuilder}; /// Start building a request `Tl-Signature` header value using private key /// pem data & the key's `kid`. @@ -32,15 +22,19 @@ pub use verify::Verifier; /// # fn main() -> Result<(), truelayer_signing::Error> { /// # let (kid, private_key, idempotency_key, body) = unimplemented!(); /// let tl_signature = truelayer_signing::sign_with_pem(kid, private_key) -/// .method("POST") +/// .method(truelayer_signing::Method::Post) /// .path("/payouts") /// .header("Idempotency-Key", idempotency_key) /// .body(body) +/// .build_signer() /// .sign()?; /// # Ok(()) } /// ``` -pub fn sign_with_pem<'a>(kid: &'a str, private_key_pem: &'a [u8]) -> Signer<'a> { - Signer::new(kid, private_key_pem) +pub fn sign_with_pem<'a>( + kid: &'a str, + private_key_pem: &'a [u8], +) -> SignerBuilder<'a, &'a str, &'a [u8], Unset, Unset, Unset> { + SignerBuilder::build_with_pem(kid, private_key_pem) } /// Start building a `Tl-Signature` header verifier using public key pem data. @@ -50,16 +44,19 @@ pub fn sign_with_pem<'a>(kid: &'a str, private_key_pem: &'a [u8]) -> Signer<'a> /// # fn main() -> Result<(), truelayer_signing::Error> { /// # let (public_key, idempotency_key, body, tl_signature) = unimplemented!(); /// truelayer_signing::verify_with_pem(public_key) -/// .method("POST") +/// .method(truelayer_signing::Method::Post) /// .path("/payouts") /// .require_header("Idempotency-Key") /// .header("Idempotency-Key", idempotency_key) /// .body(body) +/// .build_verifier() /// .verify(tl_signature)?; /// # Ok(()) } /// ``` -pub fn verify_with_pem(public_key_pem: &[u8]) -> Verifier<'_> { - Verifier::new(verify::PublicKey::Pem(public_key_pem)) +pub fn verify_with_pem( + public_key_pem: &[u8], +) -> VerifierBuilder<'_, PublicKey<'_>, Unset, Unset, Unset> { + VerifierBuilder::pem(public_key_pem) } /// Start building a `Tl-Signature` header verifier using public key JWKs JSON response data. @@ -73,15 +70,16 @@ pub fn verify_with_pem(public_key_pem: &[u8]) -> Verifier<'_> { /// # let headers: Vec<(&str, &[u8])> = unimplemented!(); /// // jwks json of form: {"keys":[...]} /// truelayer_signing::verify_with_jwks(jwks) -/// .method("POST") +/// .method(truelayer_signing::Method::Post) /// .path("/webhook") /// .headers(headers) /// .body(body) +/// .build_verifier() /// .verify(tl_signature)?; /// # Ok(()) } /// ``` -pub fn verify_with_jwks(jwks: &[u8]) -> Verifier<'_> { - Verifier::new(verify::PublicKey::Jwks(jwks)) +pub fn verify_with_jwks(jwks: &[u8]) -> VerifierBuilder<'_, PublicKey<'_>, Unset, Unset, Unset> { + VerifierBuilder::jwks(jwks) } /// Extract [`JwsHeader`] info from a `Tl-Signature` header value. @@ -100,4 +98,7 @@ pub enum Error { /// JWS signature generation or verification failed. #[error("jws signing/verification failed: {0}")] JwsError(anyhow::Error), + /// Other error. + #[error("Error: {0}")] + Other(anyhow::Error), } diff --git a/rust/src/openssl.rs b/rust/src/openssl.rs index 9a841481..70489ec8 100644 --- a/rust/src/openssl.rs +++ b/rust/src/openssl.rs @@ -1,4 +1,5 @@ use anyhow::{ensure, Context}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use openssl::{ bn::BigNum, ec::EcKey, @@ -106,8 +107,8 @@ impl Jwk { ensure!(self.kty == "EC", "unsupported jwk kty"); ensure!(self.crv == "P-521", "unsupported jwk crv"); - let x = base64::decode_config(self.x, base64::URL_SAFE_NO_PAD)?; - let y = base64::decode_config(self.y, base64::URL_SAFE_NO_PAD)?; + let x = URL_SAFE_NO_PAD.decode(self.x)?; + let y = URL_SAFE_NO_PAD.decode(self.y)?; let x = openssl::bn::BigNum::from_slice(&x)?; let y = openssl::bn::BigNum::from_slice(&y)?; diff --git a/rust/src/sign.rs b/rust/src/sign.rs deleted file mode 100644 index af3d09ee..00000000 --- a/rust/src/sign.rs +++ /dev/null @@ -1,237 +0,0 @@ -use crate::{base64::ToUrlSafeBase64, http::HeaderName, jws::JwsHeader, openssl, Error}; -use indexmap::IndexMap; -use std::fmt; - -/// Builder to generate a `Tl-Signature` header value using a private key. -/// -/// See [`crate::sign_with_pem`] for examples. -pub struct Signer<'a> { - kid: &'a str, - private_key: &'a [u8], - body: &'a [u8], - method: &'a str, - path: &'a str, - headers: IndexMap, &'a [u8]>, - jws_jku: Option<&'a str>, -} - -/// Debug does not display key info. -impl fmt::Debug for Signer<'_> { - fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { - write!(fmt, "Signer") - } -} - -impl<'a> Signer<'a> { - pub(crate) fn new(kid: &'a str, private_key_pem: &'a [u8]) -> Self { - Self { - kid, - private_key: private_key_pem, - body: &[], - method: "POST", - path: "", - headers: <_>::default(), - jws_jku: <_>::default(), - } - } - - /// Add the full request body. - /// - /// Note: This **must** be identical to what is sent with the request. - /// - /// # Example - /// ``` - /// # let (kid, key) = ("", &[]); - /// truelayer_signing::sign_with_pem(kid, key) - /// .body(b"{...}"); - /// ``` - pub fn body(mut self, body: &'a [u8]) -> Self { - self.body = body; - self - } - - /// Add the request method, defaults to `"POST"` if unspecified. - /// - /// # Example - /// ``` - /// # let (kid, key) = ("", &[]); - /// truelayer_signing::sign_with_pem(kid, key) - /// .method("POST"); - /// ``` - pub fn method(mut self, method: &'a str) -> Self { - self.method = method; - self - } - - /// Add the request absolute path starting with a leading `/` and without - /// any trailing slashes. - /// - /// # Panics - /// If `path` does not start with a '/' char. - /// - /// # Example - /// ``` - /// # let (kid, key) = ("", &[]); - /// truelayer_signing::sign_with_pem(kid, key) - /// .path("/payouts"); - /// ``` - pub fn path(mut self, path: &'a str) -> Self { - assert!( - path.starts_with('/'), - "Invalid path \"{path}\" must start with '/'" - ); - self.path = path; - self - } - - /// Add a header name & value. - /// May be called multiple times to add multiple different headers. - /// - /// Warning: Only a single value per header name is supported. - /// - /// # Example - /// ``` - /// # let (kid, key) = ("", &[]); - /// truelayer_signing::sign_with_pem(kid, key) - /// .header("Idempotency-Key", b"60df4d00-9778-4297-be6d-817d7a6d27bb"); - /// ``` - pub fn header(mut self, key: &'a str, value: &'a [u8]) -> Self { - self.add_header(key, value); - self - } - - /// Add a header name & value. - /// May be called multiple times to add multiple different headers. - /// - /// Warning: Only a single value per header name is supported. - /// - /// # Example - /// ``` - /// # let mut signer = truelayer_signing::sign_with_pem("", &[]); - /// signer.add_header("Idempotency-Key", b"60df4d00-9778-4297-be6d-817d7a6d27bb"); - /// ``` - pub fn add_header(&mut self, key: &'a str, value: &'a [u8]) { - self.headers.insert(HeaderName(key), value); - } - - /// Appends multiple header names & values. - /// - /// Warning: Only a single value per header name is supported. - /// - /// # Example - /// ``` - /// # let (kid, key) = ("", &[]); - /// truelayer_signing::sign_with_pem(kid, key) - /// .headers([("X-Head-A", "123".as_bytes()), ("X-Head-B", "345".as_bytes())]); - /// ``` - pub fn headers(mut self, headers: impl IntoIterator) -> Self { - self.headers - .extend(headers.into_iter().map(|(k, v)| (HeaderName(k), v))); - self - } - - /// Sets the jws header `jku` JSON Web Key URL. - /// - /// Note: This is not generally required when calling APIs, - /// but is set on webhook signatures. - pub fn jku(mut self, jku: &'a str) -> Self { - self.jws_jku = Some(jku); - self - } - - /// Produce a JWS `Tl-Signature` v1 header value, signing just the request body. - /// - /// Any specified method, path & headers will be ignored. - /// - /// In general full request signing should be preferred, see [`Signer::sign`]. - pub fn sign_body_only(&self) -> Result { - let private_key = - openssl::parse_ec_private_key(self.private_key).map_err(Error::InvalidKey)?; - - let jws_header = { - let mut header = serde_json::json!({ - "alg": "ES512", - "kid": self.kid, - }); - if let Some(jku) = self.jws_jku { - header["jku"] = jku.into(); - } - serde_json::to_string(&header) - .map_err(|e| Error::JwsError(e.into()))? - .to_url_safe_base64() - }; - let jws_header_and_payload = format!("{}.{}", jws_header, self.body.to_url_safe_base64()); - - let signature = openssl::sign_es512(&private_key, jws_header_and_payload.as_bytes()) - .map_err(Error::JwsError)? - .to_url_safe_base64(); - - let mut jws = jws_header; - jws.push_str(".."); - jws.push_str(&signature); - - Ok(jws) - } - - /// Produce a JWS `Tl-Signature` v2 header value. - pub fn sign(&self) -> Result { - let private_key = - openssl::parse_ec_private_key(self.private_key).map_err(Error::InvalidKey)?; - - let jws_header = JwsHeader::new_v2(self.kid, &self.headers, self.jws_jku.map(|u| u.into())); - let jws_header_b64 = serde_json::to_string(&jws_header) - .map_err(|e| Error::JwsError(e.into()))? - .to_url_safe_base64(); - - let signing_payload = - build_v2_signing_payload(self.method, self.path, &self.headers, self.body, false); - - let jws_header_and_payload = format!( - "{}.{}", - jws_header_b64, - signing_payload.to_url_safe_base64() - ); - let signature = openssl::sign_es512(&private_key, jws_header_and_payload.as_bytes()) - .map_err(Error::JwsError)? - .to_url_safe_base64(); - - let mut jws = jws_header_b64; - jws.push_str(".."); - jws.push_str(&signature); - - Ok(jws) - } -} - -/// Build a v2 signing payload. -/// -/// # Example -/// ```txt -/// POST /test-signature -/// Idempotency-Key: 619410b3-b00c-406e-bb1b-2982f97edb8b -/// {"bar":123} -/// ``` -pub(crate) fn build_v2_signing_payload( - method: &str, - path: &str, - headers: &IndexMap, &[u8]>, - body: &[u8], - add_path_trailing_slash: bool, -) -> Vec { - let mut payload = Vec::new(); - payload.extend(method.to_ascii_uppercase().as_bytes()); - payload.push(b' '); - payload.extend(path.as_bytes()); - if add_path_trailing_slash { - payload.push(b'/'); - } - payload.push(b'\n'); - for (h_name, h_val) in headers { - payload.extend(h_name.0.as_bytes()); - payload.extend(b": "); - payload.extend(*h_val); - payload.push(b'\n'); - } - payload.extend(body); - payload -} diff --git a/rust/src/sign/custom_signer.rs b/rust/src/sign/custom_signer.rs new file mode 100644 index 00000000..65e164b0 --- /dev/null +++ b/rust/src/sign/custom_signer.rs @@ -0,0 +1,69 @@ +use std::future::Future; + +use indexmap::IndexMap; + +use crate::{base64::ToUrlSafeBase64, http::HeaderName, Error, JwsHeader}; + +use super::build_v2_signing_payload; + +/// Builder to generate a `Tl-Signature` header value with a custom signer. +/// +/// # Example +/// ```no_run +/// # fn main() -> Result<(), truelayer_signing::Error> { +/// # let (kid, idempotency_key, body) = unimplemented!(); +/// let tl_signature = truelayer_signing::SignerBuilder::new() +/// .kid(kid) +/// .method(truelayer_signing::Method::Post) +/// .path("/payouts") +/// .header("Idempotency-Key", idempotency_key) +/// .body(body) +/// .build_custom_signer() +/// .sign_with(|bytes| todo!())?; +/// # Ok(()) } +/// ``` +pub struct CustomSigner<'a> { + pub(crate) kid: &'a str, + pub(crate) body: &'a [u8], + pub(crate) method: &'static str, + pub(crate) path: &'a str, + pub(crate) headers: IndexMap, &'a [u8]>, + pub(crate) jws_jku: Option<&'a str>, +} + +impl<'a> CustomSigner<'a> { + fn build_jws_header_and_payload(&self) -> Result<(String, String), Error> { + let jws_header = JwsHeader::new_v2(self.kid, &self.headers, self.jws_jku); + let jws_header_b64 = serde_json::to_string(&jws_header) + .map_err(|e| Error::JwsError(e.into()))? + .to_url_safe_base64(); + + let signing_payload = + build_v2_signing_payload(self.method, self.path, &self.headers, self.body, false); + + Ok((jws_header_b64, signing_payload.to_url_safe_base64())) + } + + /// Produce a JWS `Tl-Signature` v2 header value with a custom signer. + pub fn sign_with( + self, + sign_fn: impl FnOnce(&[u8]) -> Result, + ) -> Result { + let (jws_header, payload) = self.build_jws_header_and_payload()?; + let sig_payload = format!("{}.{}", jws_header, &payload); + let signature = sign_fn(sig_payload.as_bytes())?; + Ok(format!("{}..{}", jws_header, signature)) + } + + /// Produce a JWS `Tl-Signature` v2 header value with a custom signer. + pub async fn async_sign_with(self, sign_fn: F) -> Result + where + F: FnOnce(&[u8]) -> Fut, + Fut: Future>, + { + let (jws_header, payload) = self.build_jws_header_and_payload()?; + let sig_payload = format!("{}.{}", jws_header, payload); + let signature = sign_fn(sig_payload.as_bytes()).await?; + Ok(format!("{}..{}", jws_header, signature)) + } +} diff --git a/rust/src/sign/mod.rs b/rust/src/sign/mod.rs new file mode 100644 index 00000000..42c392d5 --- /dev/null +++ b/rust/src/sign/mod.rs @@ -0,0 +1,338 @@ +mod custom_signer; +mod signer_v1; + +use indexmap::IndexMap; +use std::fmt; + +use crate::{base64::ToUrlSafeBase64, common::Unset, http::HeaderName, openssl, Error, Method}; + +pub use self::custom_signer::CustomSigner; +use self::signer_v1::SignerV1; + +/// Builder to generate a `Tl-Signature` header value. +/// +/// # Example +/// ```no_run +/// # fn main() -> Result<(), truelayer_signing::Error> { +/// # let (kid, private_key, idempotency_key, body) = unimplemented!(); +/// let tl_signature = truelayer_signing::SignerBuilder::build_with_pem(kid, private_key) +/// .method(truelayer_signing::Method::Post) +/// .path("/payouts") +/// .header("Idempotency-Key", idempotency_key) +/// .body(body) +/// .build_signer() +/// .sign()?; +/// # Ok(()) } +/// ``` +#[derive(Default)] +pub struct SignerBuilder<'a, Kid, Pk, Body, Method, Path> { + kid: Kid, + private_key: Pk, + body: Body, + method: Method, + path: Path, + headers: IndexMap, &'a [u8]>, + jws_jku: Option<&'a str>, +} + +impl fmt::Debug for SignerBuilder<'_, Kid, Pk, Body, Method, Path> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "Signer") + } +} + +impl<'a> SignerBuilder<'a, Unset, Unset, Unset, Unset, Unset> { + pub fn new() -> Self { + SignerBuilder { + kid: Unset, + private_key: Unset, + body: Unset, + method: Unset, + path: Unset, + headers: <_>::default(), + jws_jku: <_>::default(), + } + } +} + +impl<'a> SignerBuilder<'a, &'a str, &'a [u8], Unset, Unset, Unset> { + pub fn build_with_pem(kid: &'a str, private_key: &'a [u8]) -> Self { + SignerBuilder { + kid, + private_key, + body: Unset, + method: Unset, + path: Unset, + headers: <_>::default(), + jws_jku: <_>::default(), + } + } +} + +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> { + SignerBuilder { + kid, + private_key: self.private_key, + body: self.body, + method: self.method, + path: self.path, + headers: self.headers, + jws_jku: self.jws_jku, + } + } +} + +impl<'a, K, Body, Method, Path> SignerBuilder<'a, K, Unset, Body, Method, Path> { + /// Add the private key. + pub fn private_key( + self, + private_key: &[u8], + ) -> SignerBuilder<'a, K, &[u8], Body, Method, Path> { + SignerBuilder { + kid: self.kid, + private_key, + body: self.body, + method: self.method, + path: self.path, + headers: self.headers, + jws_jku: self.jws_jku, + } + } +} + +impl<'a, K, Pk, Method, Path> SignerBuilder<'a, K, Pk, Unset, Method, Path> { + /// Add the full request body. + /// + /// Note: This **must** be identical to what is sent with the request. + pub fn body(self, body: &[u8]) -> SignerBuilder<'a, K, Pk, &[u8], Method, Path> { + SignerBuilder { + kid: self.kid, + private_key: self.private_key, + body, + method: self.method, + path: self.path, + headers: self.headers, + jws_jku: self.jws_jku, + } + } +} + +impl<'a, K, Pk, Path> SignerBuilder<'a, K, Pk, Unset, Unset, Path> { + /// Add the request method. + /// + /// If the method is GET the body will be ignored. + pub fn method(self, method: Method) -> SignerBuilder<'a, K, Pk, Unset, Method, Path> { + SignerBuilder { + kid: self.kid, + private_key: self.private_key, + body: self.body, + method, + path: self.path, + headers: self.headers, + jws_jku: self.jws_jku, + } + } +} + +impl<'a, K, Pk, Path> SignerBuilder<'a, K, Pk, &'a [u8], Unset, Path> { + /// Add the request method. + /// + /// If the method is GET the body will be ignored. + pub fn method(self, method: Method) -> SignerBuilder<'a, K, Pk, &'a [u8], Method, Path> { + SignerBuilder { + kid: self.kid, + private_key: self.private_key, + body: match method { + Method::Get => &[], + Method::Post => self.body, + }, + method, + path: self.path, + headers: self.headers, + jws_jku: self.jws_jku, + } + } +} + +impl<'a, K, Pk, Body, Method> SignerBuilder<'a, K, Pk, Body, Method, Unset> { + /// Add the request absolute path starting with a leading `/` and without + /// any trailing slashes. + pub fn path(self, path: &str) -> SignerBuilder<'a, K, Pk, Body, Method, &str> { + assert!( + path.starts_with('/'), + "Invalid path \"{path}\" must start with '/'" + ); + SignerBuilder { + kid: self.kid, + private_key: self.private_key, + body: self.body, + method: self.method, + path, + headers: self.headers, + jws_jku: self.jws_jku, + } + } +} + +impl<'a, K, Pk, Body, Method, Path> SignerBuilder<'a, K, Pk, Body, Method, Path> { + /// Add a header name & value. + /// May be called multiple times to add multiple different headers. + /// + /// Warning: Only a single value per header name is supported. + pub fn header(mut self, key: &'a str, value: &'a [u8]) -> Self { + self.headers.insert(HeaderName(key), value); + self + } + + /// Appends multiple header names & values. + /// + /// Warning: Only a single value per header name is supported. + pub fn headers(mut self, headers: impl IntoIterator) -> Self { + self.headers + .extend(headers.into_iter().map(|(k, v)| (HeaderName(k), v))); + self + } + + /// Sets the jws header `jku` JSON Web Key URL. + /// + /// Note: This is not generally required when calling APIs, + /// but is set on webhook signatures. + pub fn jku(mut self, jku: &'a str) -> Self { + self.jws_jku = Some(jku); + self + } +} + +impl<'a> SignerBuilder<'a, &'a str, Unset, &'a [u8], Method, &'a str> { + /// Builds a [`CustomSigner`] + /// + /// requires the kid, body, method, and path to be set to call this function. + /// if the private key is set this function will not be available. + pub fn build_custom_signer(self) -> CustomSigner<'a> { + CustomSigner { + kid: self.kid, + body: match self.method { + Method::Get => &[], + Method::Post => self.body, + }, + method: self.method.name(), + path: self.path, + headers: self.headers, + jws_jku: self.jws_jku, + } + } +} + +impl<'a> SignerBuilder<'a, &'a str, &'a [u8], &'a [u8], Unset, Unset> { + /// Build a V1 Signer see [`SignerV1`]. + /// + /// requires the private key, kid, and body to be set to call this function. + /// if the method of path is set this function will not be available. + /// + /// In general full request signing should be preferred, see [`Signer`]. + pub fn build_v1_signer(self) -> SignerV1<'a> { + SignerV1 { + private_key: self.private_key, + kid: self.kid, + body: self.body, + jws_jku: self.jws_jku, + } + } +} + +impl<'a> SignerBuilder<'a, &'a str, &'a [u8], &'a [u8], Method, &'a str> { + /// Build a V2 Signer see [`Signer`]. + /// + /// requires the private key, kid, body, method, and path to be set to call this function. + pub fn build_signer(self) -> Signer<'a> { + Signer { + private_key: self.private_key, + base: CustomSigner { + kid: self.kid, + body: match self.method { + Method::Get => &[], + Method::Post => self.body, + }, + method: self.method.name(), + path: self.path, + headers: self.headers, + jws_jku: self.jws_jku, + }, + } + } +} + +/// Signer to generate a `Tl-Signature` header value using a private key. +/// +/// # Example +/// ```no_run +/// # fn main() -> Result<(), truelayer_signing::Error> { +/// # let (kid, private_key, idempotency_key, body) = unimplemented!(); +/// let tl_signature = truelayer_signing::SignerBuilder::build_with_pem(kid, private_key) +/// .method(truelayer_signing::Method::Post) +/// .path("/payouts") +/// .header("Idempotency-Key", idempotency_key) +/// .body(body) +/// .build_signer() +/// .sign()?; +/// # Ok(()) } +/// ``` +pub struct Signer<'a> { + base: CustomSigner<'a>, + private_key: &'a [u8], +} + +/// Debug does not display key info. +impl fmt::Debug for Signer<'_> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "Signer") + } +} + +impl<'a> Signer<'a> { + /// Produce a JWS `Tl-Signature` v2 header value. + pub fn sign(self) -> Result { + let private_key = + openssl::parse_ec_private_key(self.private_key).map_err(Error::InvalidKey)?; + self.base.sign_with(|bytes| { + openssl::sign_es512(&private_key, bytes) + .map(|sig| sig.to_url_safe_base64()) + .map_err(Error::JwsError) + }) + } +} + +/// Build a v2 signing payload. +/// +/// # Example +/// ```txt +/// POST /test-signature +/// Idempotency-Key: 619410b3-b00c-406e-bb1b-2982f97edb8b +/// {"bar":123} +/// ``` +pub(crate) fn build_v2_signing_payload( + method: &str, + path: &str, + headers: &IndexMap, &[u8]>, + body: &[u8], + add_path_trailing_slash: bool, +) -> Vec { + let mut payload = Vec::new(); + payload.extend(method.as_bytes()); + payload.push(b' '); + payload.extend(path.as_bytes()); + if add_path_trailing_slash { + payload.push(b'/'); + } + payload.push(b'\n'); + for (h_name, h_val) in headers { + payload.extend(h_name.0.as_bytes()); + payload.extend(b": "); + payload.extend(*h_val); + payload.push(b'\n'); + } + payload.extend(body); + payload +} diff --git a/rust/src/sign/signer_v1.rs b/rust/src/sign/signer_v1.rs new file mode 100644 index 00000000..e2aee078 --- /dev/null +++ b/rust/src/sign/signer_v1.rs @@ -0,0 +1,49 @@ +use crate::{base64::ToUrlSafeBase64, openssl, Error}; + +/// Produce a JWS `Tl-Signature` v1 header value, signing just the request body. +/// +/// Any specified method, path & headers will be ignored. +/// +/// In general full request signing should be preferred, see [`Signer::sign`]. +pub struct SignerV1<'a> { + pub(crate) private_key: &'a [u8], + pub(crate) kid: &'a str, + pub(crate) body: &'a [u8], + pub(crate) jws_jku: Option<&'a str>, +} + +impl<'a> SignerV1<'a> { + /// Produce a JWS `Tl-Signature` v1 header value, signing just the request body. + /// + /// Any specified method, path & headers will be ignored. + /// + /// In general full request signing should be preferred, see [`Signer::sign`]. + pub fn sign_body_only(self) -> Result { + let private_key = + openssl::parse_ec_private_key(self.private_key).map_err(Error::InvalidKey)?; + + let jws_header = { + let mut header = serde_json::json!({ + "alg": "ES512", + "kid": self.kid, + }); + if let Some(jku) = self.jws_jku { + header["jku"] = jku.into(); + } + serde_json::to_string(&header) + .map_err(|e| Error::JwsError(e.into()))? + .to_url_safe_base64() + }; + let jws_header_and_payload = format!("{}.{}", jws_header, self.body.to_url_safe_base64()); + + let signature = openssl::sign_es512(&private_key, jws_header_and_payload.as_bytes()) + .map_err(Error::JwsError)? + .to_url_safe_base64(); + + let mut jws = jws_header; + jws.push_str(".."); + jws.push_str(&signature); + + Ok(jws) + } +} diff --git a/rust/src/verify.rs b/rust/src/verify.rs deleted file mode 100644 index c53ae597..00000000 --- a/rust/src/verify.rs +++ /dev/null @@ -1,220 +0,0 @@ -use crate::{ - base64::{DecodeUrlSafeBase64, ToUrlSafeBase64}, - http::HeaderName, - jws::JwsHeader, - openssl, - sign::build_v2_signing_payload, - Error, -}; -use anyhow::anyhow; -use indexmap::{IndexMap, IndexSet}; -use std::fmt; - -/// Builder to verify a request against a `Tl-Signature` header. -/// -/// See [`crate::verify_with_pem`] for examples. -pub struct Verifier<'a> { - public_key: PublicKey<'a>, - body: &'a [u8], - method: &'a str, - path: &'a str, - headers: IndexMap, &'a [u8]>, - required_headers: IndexSet>, - allow_v1: bool, -} - -/// Public key for verification. -#[derive(Clone, Copy)] -pub(crate) enum PublicKey<'a> { - /// Public key PEM. - Pem(&'a [u8]), - /// JWKs JSON response. - Jwks(&'a [u8]), -} - -/// Debug does not display key info. -impl fmt::Debug for Verifier<'_> { - fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { - write!(fmt, "Verifier") - } -} - -impl<'a> Verifier<'a> { - pub(crate) fn new(public_key: PublicKey<'a>) -> Self { - Self { - public_key, - body: &[], - method: "", - path: "", - headers: <_>::default(), - required_headers: <_>::default(), - allow_v1: false, - } - } - - /// Add the full received request body. - pub fn body(mut self, body: &'a [u8]) -> Self { - self.body = body; - self - } - - /// Add the request method, e.g. `"POST"`. - pub fn method(mut self, method: &'a str) -> Self { - self.method = method; - self - } - - /// Add the request path, e.g. `"/payouts"`. - /// - /// # Panics - /// If `path` does not start with a '/' char. - pub fn path(mut self, path: &'a str) -> Self { - assert!( - path.starts_with('/'), - "Invalid path \"{path}\" must start with '/'", - ); - self.path = path; - self - } - - /// Add a header name & value. - /// May be called multiple times to add multiple different headers. - /// - /// All request headers may be added here, any headers not mentioned - /// in the jws signature header will be ignored unless required using - /// [`Verifier::require_header`]. - pub fn header(mut self, key: &'a str, value: &'a [u8]) -> Self { - self.add_header(key, value); - self - } - - /// Add a header name & value. - /// May be called multiple times to add multiple different headers. - /// - /// All request headers may be added here, any headers not mentioned - /// in the jws signature header will be ignored unless required using - /// [`Verifier::require_header`]. - pub fn add_header(&mut self, key: &'a str, value: &'a [u8]) { - self.headers.insert(HeaderName(key), value); - } - - /// Appends multiple header names & values. - /// - /// All request headers may be added here, any headers not mentioned - /// in the jws signature header will be ignored unless required using - /// [`Verifier::require_header`]. - /// - /// # Example - /// ``` - /// # let key = &[]; - /// truelayer_signing::verify_with_pem(key) - /// .headers([("X-Head-A", "123".as_bytes()), ("X-Head-B", "345".as_bytes())]); - /// ``` - pub fn headers(mut self, headers: impl IntoIterator) -> Self { - self.headers - .extend(headers.into_iter().map(|(k, v)| (HeaderName(k), v))); - self - } - - /// Require a header name that must be included in the `Tl-Signature`. - /// May be called multiple times to add multiple required headers. - /// - /// Signatures missing these will fail verification. - pub fn require_header(mut self, key: &'a str) -> Self { - self.required_headers.insert(HeaderName(key)); - self - } - - /// Sets whether v1 body-only signatures are allowed to pass verification. - /// Default `false`. - /// - /// `true` means both v1 & v2 signatures are allowed. - pub fn allow_v1(mut self, allow: bool) -> Self { - self.allow_v1 = allow; - self - } - - /// Verify the given `Tl-Signature` header value. - /// - /// Supports v1 (body only) & v2 full request signatures. - /// - /// Returns `Err(_)` if verification fails. - pub fn verify(&self, tl_signature: &str) -> Result<(), Error> { - let (jws_header, header_b64, signature) = parse_tl_signature(tl_signature)?; - - let public_key = match self.public_key { - PublicKey::Pem(pem) => openssl::parse_ec_public_key(pem), - PublicKey::Jwks(jwks) => openssl::find_and_parse_ec_jwk(&jws_header.kid, jwks), - } - .map_err(Error::InvalidKey)?; - - if jws_header.alg != "ES512" { - return Err(Error::JwsError(anyhow!("unexpected header alg"))); - } - - if jws_header.tl_version.is_empty() || jws_header.tl_version == "1" { - if !self.allow_v1 { - return Err(Error::JwsError(anyhow!("v1 signature not allowed"))); - } - - // v1 signature: body only - let payload = format!("{header_b64}.{}", self.body.to_url_safe_base64()); - return openssl::verify_es512(&public_key, payload.as_bytes(), &signature) - .map_err(Error::JwsError); - } - - // check and order all required headers - let ordered_headers = jws_header - .filter_headers(&self.headers) - .map_err(Error::JwsError)?; - - // fail if signature is missing a required header - if let Some(header) = self - .required_headers - .iter() - .find(|h| !ordered_headers.contains_key(*h)) - { - return Err(Error::JwsError(anyhow!( - "signature is missing required header {}", - header - ))); - } - - // 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); - let payload = format!("{header_b64}.{}", signing_payload.to_url_safe_base64()); - openssl::verify_es512(&public_key, payload.as_bytes(), &signature) - .or_else(|e| { - // try again with/without a trailing slash (#80) - let (path, slash) = match self.path { - p if p.ends_with('/') => (&p[..p.len() - 1], false), - p => (p, true), - }; - let signing_payload = - 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 - openssl::verify_es512(&public_key, payload.as_bytes(), &signature).map_err(|_| e) - }) - .map_err(Error::JwsError) - } -} - -/// Parse a tl signature header value into `(header, header_base64, signature)`. -pub(crate) fn parse_tl_signature(tl_signature: &str) -> Result<(JwsHeader, &str, Vec), Error> { - let (header_b64, signature_b64) = tl_signature - .split_once("..") - .ok_or_else(|| Error::JwsError(anyhow!("invalid signature format")))?; - - let header: JwsHeader = serde_json::from_slice( - &header_b64 - .decode_url_safe_base64() - .map_err(|e| Error::JwsError(anyhow!("header decode failed: {}", e)))?, - ) - .map_err(|e| Error::JwsError(anyhow!("header decode failed: {}", e)))?; - let signature = signature_b64 - .decode_url_safe_base64() - .map_err(|e| Error::JwsError(anyhow!("signature decode failed: {}", e)))?; - Ok((header, header_b64, signature)) -} diff --git a/rust/src/verify/custom_verifer.rs b/rust/src/verify/custom_verifer.rs new file mode 100644 index 00000000..3ff43eea --- /dev/null +++ b/rust/src/verify/custom_verifer.rs @@ -0,0 +1,81 @@ +use std::fmt; + +use anyhow::anyhow; +use indexmap::{IndexMap, IndexSet}; + +use crate::{ + base64::ToUrlSafeBase64, http::HeaderName, sign::build_v2_signing_payload, Error, JwsHeader, +}; + +use super::parse_tl_signature; + +/// A `Tl-Signature` Verifier for custom signature verification. +pub struct CustomVerifier<'a> { + pub(crate) body: &'a [u8], + pub(crate) method: &'static str, + pub(crate) path: &'a str, + pub(crate) headers: IndexMap, &'a [u8]>, + pub(crate) required_headers: IndexSet>, + pub(crate) parsed_tl_sig: Option<(JwsHeader<'a>, &'a str, Vec)>, +} + +/// Debug does not display key info. +impl fmt::Debug for CustomVerifier<'_> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "Verifier") + } +} + +impl<'a> CustomVerifier<'a> { + pub fn verify_with( + mut self, + tl_signature: &'a str, + 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"))); + } + + // check and order all required headers + let ordered_headers = jws_header + .filter_headers(&self.headers) + .map_err(Error::JwsError)?; + + // fail if signature is missing a required header + if let Some(header) = self + .required_headers + .iter() + .find(|h| !ordered_headers.contains_key(*h)) + { + return Err(Error::JwsError(anyhow!( + "signature is missing required header {}", + header + ))); + } + + // 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); + let payload = format!("{header_b64}.{}", signing_payload.to_url_safe_base64()); + + verify_fn(payload.as_bytes(), signature.as_slice()).or_else(|e| { + // try again with/without a trailing slash (#80) + let (path, slash) = match self.path { + p if p.ends_with('/') => (&p[..p.len() - 1], false), + p => (p, true), + }; + let signing_payload = + 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) + }) + } +} diff --git a/rust/src/verify/mod.rs b/rust/src/verify/mod.rs new file mode 100644 index 00000000..f497b1f3 --- /dev/null +++ b/rust/src/verify/mod.rs @@ -0,0 +1,309 @@ +use std::fmt; + +use anyhow::anyhow; +use indexmap::{IndexMap, IndexSet}; + +use crate::{ + base64::DecodeUrlSafeBase64, common::Unset, http::HeaderName, openssl, Error, JwsHeader, Method, +}; + +pub use self::custom_verifer::CustomVerifier; +use self::verifier_v1::VerifierV1; + +mod custom_verifer; +mod verifier_v1; + +/// Builder to verify a request against a `Tl-Signature` header. +/// +/// # Example +/// ```no_run +/// # fn main() -> Result<(), truelayer_signing::Error> { +/// # let (public_key, idempotency_key, body, tl_signature) = unimplemented!(); +/// truelayer_signing::VerifierBuilder::pem(public_key) +/// .method(truelayer_signing::Method::Post) +/// .path("/payouts") +/// .require_header("Idempotency-Key") +/// .header("X-Whatever", b"aoitbeh") +/// .header("Idempotency-Key", idempotency_key) +/// .body(body) +/// .build_verifier() +/// .verify(tl_signature) +/// .expect("verify"); +/// # } +/// ``` +#[derive(Default)] +pub struct VerifierBuilder<'a, Pk, Body, Method, Path> { + public_key: Pk, + body: Body, + method: Method, + path: Path, + headers: IndexMap, &'a [u8]>, + required_headers: IndexSet>, +} + +/// Public key for verification. +#[derive(Clone, Copy)] +pub enum PublicKey<'a> { + /// Public key PEM. + Pem(&'a [u8]), + /// JWKs JSON response. + Jwks(&'a [u8]), +} + +/// Debug does not display key info. +impl fmt::Debug for VerifierBuilder<'_, Pk, Body, Method, Path> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "Verifier") + } +} + +impl<'a> VerifierBuilder<'a, PublicKey<'a>, Unset, Unset, Unset> { + /// Add public key via pem. + pub fn pem(pem: &'a [u8]) -> VerifierBuilder<'a, PublicKey<'a>, Unset, Unset, Unset> { + VerifierBuilder { + public_key: PublicKey::Pem(pem), + body: Unset, + method: Unset, + path: Unset, + headers: <_>::default(), + required_headers: <_>::default(), + } + } + + /// Add public key via a jwks. + pub fn jwks(jwk: &'a [u8]) -> VerifierBuilder<'a, PublicKey<'a>, Unset, Unset, Unset> { + VerifierBuilder { + public_key: PublicKey::Jwks(jwk), + body: Unset, + method: Unset, + path: Unset, + headers: <_>::default(), + required_headers: <_>::default(), + } + } +} + +impl<'a, Pk, Method, Path> VerifierBuilder<'a, Pk, Unset, Method, Path> { + /// Add the full received request body. + pub fn body(self, body: &'a [u8]) -> VerifierBuilder<'a, Pk, &'a [u8], Method, Path> { + VerifierBuilder { + public_key: self.public_key, + body, + method: self.method, + path: self.path, + headers: self.headers, + required_headers: self.required_headers, + } + } +} + +impl<'a, Pk, Body, Path> VerifierBuilder<'a, Pk, Body, Unset, Path> { + /// Add the request method. + pub fn method(self, method: Method) -> VerifierBuilder<'a, Pk, Body, Method, Path> { + VerifierBuilder { + public_key: self.public_key, + body: self.body, + method, + path: self.path, + headers: self.headers, + required_headers: self.required_headers, + } + } +} + +impl<'a, Pk, Body, Method> VerifierBuilder<'a, Pk, Body, Method, Unset> { + /// Add the request path, e.g. `"/payouts"`. + /// + /// # Panics + /// If `path` does not start with a '/' char. + pub fn path(self, path: &'a str) -> VerifierBuilder<'a, Pk, Body, Method, &'a str> { + assert!( + path.starts_with('/'), + "Invalid path \"{path}\" must start with '/'" + ); + VerifierBuilder { + public_key: self.public_key, + body: self.body, + method: self.method, + path, + headers: self.headers, + required_headers: self.required_headers, + } + } +} + +impl<'a, Pk, Body, Method, Path> VerifierBuilder<'a, Pk, Body, Method, Path> { + /// Add a header name & value. + /// May be called multiple times to add multiple different headers. + /// + /// All request headers may be added here, any headers not mentioned + /// in the jws signature header will be ignored unless required using + /// [`Verifier::require_header`]. + pub fn header(mut self, key: &'a str, value: &'a [u8]) -> Self { + self.headers.insert(HeaderName(key), value); + self + } + + /// Appends multiple header names & values. + /// + /// All request headers may be added here, any headers not mentioned + /// in the jws signature header will be ignored unless required using + /// [`Verifier::require_header`]. + /// + /// # Example + /// ```no_run + /// # let public_key = unimplemented!(); + /// truelayer_signing::VerifierBuilder::pem(public_key) + /// .headers([("X-Head-A", "123".as_bytes()), ("X-Head-B", "345".as_bytes())]); + /// ``` + pub fn headers(mut self, headers: impl IntoIterator) -> Self { + self.headers + .extend(headers.into_iter().map(|(k, v)| (HeaderName(k), v))); + self + } + + /// Require a header name that must be included in the `Tl-Signature`. + /// May be called multiple times to add multiple required headers. + /// + /// Signatures missing these will fail verification. + pub fn require_header(mut self, key: &'a str) -> Self { + self.required_headers.insert(HeaderName(key)); + self + } +} + +impl<'a> VerifierBuilder<'a, PublicKey<'a>, &'a [u8], Method, &'a str> { + /// Build a V2 Verifier see [`Verifier`]. + /// + /// requires the public key, body, method, and path to be set to call this function. + pub fn build_verifier(self) -> Verifier<'a> { + Verifier { + base: CustomVerifier { + body: match self.method { + Method::Get => &[], + Method::Post => self.body, + }, + method: self.method.name(), + path: self.path, + headers: self.headers, + required_headers: self.required_headers, + parsed_tl_sig: None, + }, + public_key: self.public_key, + } + } +} + +impl<'a> VerifierBuilder<'a, PublicKey<'a>, &'a [u8], Unset, Unset> { + /// Build a V1 Verifier see [`VerifierV1`]. + /// + /// requires the public key and body to be set to call this function. + pub fn build_v1_verifier(self) -> VerifierV1<'a> { + VerifierV1 { + public_key: self.public_key, + body: self.body, + parsed_tl_sig: None, + } + } +} + +/// Verify the given `Tl-Signature` header value. +/// +/// # Example +/// ```no_run +/// # fn main() -> Result<(), truelayer_signing::Error> { +/// # let (public_key, idempotency_key, body, tl_signature) = unimplemented!(); +/// truelayer_signing::VerifierBuilder::pem(public_key) +/// .method(truelayer_signing::Method::Post) +/// .path("/payouts") +/// .require_header("Idempotency-Key") +/// .header("X-Whatever", b"aoitbeh") +/// .header("Idempotency-Key", idempotency_key) +/// .body(body) +/// .build_verifier() +/// .verify(tl_signature) +/// .expect("verify"); +/// # } +/// ``` +pub struct Verifier<'a> { + base: CustomVerifier<'a>, + public_key: PublicKey<'a>, +} + +/// Debug does not display key info. +impl fmt::Debug for Verifier<'_> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "Verifier") + } +} + +impl<'a> Verifier<'a> { + /// Verify the given `Tl-Signature` header value. + /// + /// Supports v2 full request signatures. + /// + /// Returns `Err(_)` if verification fails. + pub fn verify(mut self, tl_signature: &'a str) -> Result<(), Error> { + let jws_header = unsafe { + if self.base.parsed_tl_sig.is_none() { + self.base.parsed_tl_sig = Some(parse_tl_signature(tl_signature)?); + } + &self.base.parsed_tl_sig.as_ref().unwrap_unchecked().0 + }; + + let public_key = match self.public_key { + PublicKey::Pem(pem) => openssl::parse_ec_public_key(pem), + PublicKey::Jwks(jwks) => openssl::find_and_parse_ec_jwk(&jws_header.kid, jwks), + } + .map_err(Error::InvalidKey)?; + + self.base.verify_with(tl_signature, |payload, signature| { + openssl::verify_es512(&public_key, payload, signature).map_err(Error::JwsError) + }) + } + + /// Verify the given `Tl-Signature` header value. + /// + /// Supports v1 (body only) & v2 full request signatures. + /// + /// Returns `Err(_)` if verification fails. + pub fn verify_v1_or_v2(mut self, tl_signature: &'a str) -> Result<(), Error> { + let jws_header = unsafe { + if self.base.parsed_tl_sig.is_none() { + self.base.parsed_tl_sig = Some(parse_tl_signature(tl_signature)?); + } + &self.base.parsed_tl_sig.as_ref().unwrap_unchecked().0 + }; + + if jws_header.tl_version.is_empty() || jws_header.tl_version == "1" { + VerifierV1 { + public_key: self.public_key, + body: self.base.body, + parsed_tl_sig: self.base.parsed_tl_sig, + } + .verify_body_only(tl_signature) + } else { + self.verify(tl_signature) + } + } +} + +/// Parse a tl signature header value into `(header, header_base64, signature)`. +pub(crate) fn parse_tl_signature(tl_signature: &str) -> Result<(JwsHeader, &str, Vec), Error> { + let (header_b64, signature_b64) = tl_signature + .split_once("..") + .ok_or_else(|| Error::JwsError(anyhow!("invalid signature format")))?; + + let header: JwsHeader = serde_json::from_slice( + &header_b64 + .decode_url_safe_base64() + .map_err(|e| Error::JwsError(anyhow!("header decode failed: {}", e)))?, + ) + .map_err(|e| Error::JwsError(anyhow!("header decode failed: {}", e)))?; + + let signature = signature_b64 + .decode_url_safe_base64() + .map_err(|e| Error::JwsError(anyhow!("signature decode failed: {}", e)))?; + + Ok((header, header_b64, signature)) +} diff --git a/rust/src/verify/verifier_v1.rs b/rust/src/verify/verifier_v1.rs new file mode 100644 index 00000000..93422fbc --- /dev/null +++ b/rust/src/verify/verifier_v1.rs @@ -0,0 +1,51 @@ +use std::fmt; + +use anyhow::anyhow; + +use crate::{base64::ToUrlSafeBase64, openssl, Error, JwsHeader}; + +use super::{parse_tl_signature, PublicKey}; + +/// A verifier for a request against a `Tl-Signature` header V1. +pub struct VerifierV1<'a> { + pub(crate) public_key: PublicKey<'a>, + pub(crate) body: &'a [u8], + pub(crate) parsed_tl_sig: Option<(JwsHeader<'a>, &'a str, Vec)>, +} + +/// Debug does not display key info. +impl fmt::Debug for VerifierV1<'_> { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "Verifier") + } +} + +impl<'a> VerifierV1<'a> { + /// Verify the given `Tl-Signature` header value. + /// + /// Supports v1 (body only) request signatures. + /// + /// Returns `Err(_)` if verification fails. + pub fn verify_body_only(mut self, tl_signature: &'a str) -> 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() + }; + + let public_key = match self.public_key { + PublicKey::Pem(pem) => openssl::parse_ec_public_key(pem), + PublicKey::Jwks(jwks) => openssl::find_and_parse_ec_jwk(&jws_header.kid, jwks), + } + .map_err(Error::InvalidKey)?; + + if jws_header.alg != "ES512" { + return Err(Error::JwsError(anyhow!("unexpected header alg"))); + } + + // v1 signature: body only + let payload = format!("{header_b64}.{}", self.body.to_url_safe_base64()); + openssl::verify_es512(&public_key, payload.as_bytes(), &signature).map_err(Error::JwsError) + } +} diff --git a/rust/tests/usage.rs b/rust/tests/usage.rs index 75f1e24b..e733d356 100644 --- a/rust/tests/usage.rs +++ b/rust/tests/usage.rs @@ -1,5 +1,4 @@ -use anyhow::anyhow; -use truelayer_signing::Error; +use truelayer_signing::{Error, Method}; const PUBLIC_KEY: &[u8] = include_bytes!("../../test-resources/ec512-public.pem"); const PRIVATE_KEY: &[u8] = include_bytes!("../../test-resources/ec512-private.pem"); @@ -19,10 +18,11 @@ fn full_request_signature() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .header("Idempotency-Key", idempotency_key) .body(body) + .build_signer() .sign() .expect("sign"); @@ -30,12 +30,13 @@ fn full_request_signature() { eprintln!("signature: {tl_signature}"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("POST") + .method(Method::Post) .path(path) .require_header("Idempotency-Key") .header("X-Whatever", b"aoitbeh") .header("Idempotency-Key", idempotency_key) .body(body) + .build_verifier() .verify(&tl_signature) .expect("verify"); } @@ -47,17 +48,19 @@ fn full_request_signature_no_headers() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("POST") + .method(Method::Post) .path(path) .header("X-Whatever", b"aoitbeh") .body(body) + .build_verifier() .verify(&tl_signature) .expect("verify"); } @@ -74,9 +77,10 @@ fn mismatched_signature_with_attached_valid_body() { d2d3D17Wd9UA"; truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("POST") + .method(Method::Post) .path("/foo") // not bar so should fail .body("{}".as_bytes()) + .build_verifier() .verify(tl_signature) .expect_err("verify should fail"); } @@ -93,9 +97,10 @@ fn mismatched_signature_with_attached_valid_body_trailing_dots() { d2d3D17Wd9UA...."; truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("POST") + .method(Method::Post) .path("/foo") // not bar so should fail .body("{}".as_bytes()) + .build_verifier() .verify(tl_signature) .expect_err("verify should fail"); } @@ -108,11 +113,12 @@ fn verify_full_request_static_signature() { let tl_signature = include_str!("../../test-resources/tl-signature.txt").trim(); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("POST") + .method(Method::Post) .path(path) .header("X-Whatever-2", b"t2345d") .header("Idempotency-Key", idempotency_key) .body(body) + .build_verifier() .verify(tl_signature) .expect("verify"); } @@ -124,11 +130,12 @@ fn verify_with_invalid_signature_should_error() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let error = truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("POST") + .method(Method::Post) .path(path) .header("X-Whatever-2", b"t2345d") .header("Idempotency-Key", idempotency_key) .body(body) + .build_verifier() .verify("an-invalid..signature"); assert!(matches!(error, Err(Error::JwsError(_)))); @@ -141,15 +148,18 @@ fn verify_without_signed_trailing_slash() { let body = br#"{"foo":"bar"}"#; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) + .method(Method::Post) .path("/tl-webhook/") .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("POST") + .method(Method::Post) .path("/tl-webhook") // missing trailing slash .body(body) + .build_verifier() .verify(&tl_signature) .expect("verify"); } @@ -161,15 +171,18 @@ fn verify_with_unsigned_trailing_slash() { let body = br#"{"foo":"bar"}"#; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) + .method(Method::Post) .path("/tl-webhook") .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("POST") + .method(Method::Post) .path("/tl-webhook/") // additional trailing slash .body(body) + .build_verifier() .verify(&tl_signature) .expect("verify"); } @@ -183,7 +196,9 @@ fn sign_an_invalid_path() { #[test] #[should_panic = r#"Invalid path "https://example.com/the-path" must start with '/'"#] fn verify_an_invalid_path() { - truelayer_signing::verify_with_pem(PUBLIC_KEY).path("https://example.com/the-path"); + truelayer_signing::verify_with_pem(PUBLIC_KEY) + .method(Method::Post) + .path("https://example.com/the-path"); } #[test] @@ -193,19 +208,21 @@ fn full_request_signature_method_mismatch() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .header("Idempotency-Key", idempotency_key) .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("DELETE") // different + .method(Method::Get) // different .path(path) .header("X-Whatever", b"aoitbeh") .header("Idempotency-Key", idempotency_key) .body(body) + .build_verifier() .verify(&tl_signature) .expect_err("verify should fail"); } @@ -217,19 +234,21 @@ fn full_request_signature_path_mismatch() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .header("Idempotency-Key", idempotency_key) .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("post") + .method(Method::Post) .path("/merchant_accounts/67b5b1cf-1d0c-45d4-a2ea-61bdc044327c/sweeping") // different .header("X-Whatever", b"aoitbeh") .header("Idempotency-Key", idempotency_key) .body(body) + .build_verifier() .verify(&tl_signature) .expect_err("verify should fail"); } @@ -241,19 +260,21 @@ fn full_request_signature_header_mismatch() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .header("Idempotency-Key", idempotency_key) .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("post") + .method(Method::Post) .path(path) .header("X-Whatever", b"aoitbeh") .header("Idempotency-Key", b"something-else") // different .body(body) + .build_verifier() .verify(&tl_signature) .expect_err("verify should fail"); } @@ -265,19 +286,21 @@ fn full_request_signature_body_mismatch() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .header("Idempotency-Key", idempotency_key) .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("post") + .method(Method::Post) .path(path) .header("X-Whatever", b"aoitbeh") .header("Idempotency-Key", idempotency_key) .body(br#"{"max_amount_in_minor":1234}"#) // different + .build_verifier() .verify(&tl_signature) .expect_err("verify should fail"); } @@ -289,19 +312,21 @@ fn full_request_signature_missing_signature_header() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .header("Idempotency-Key", idempotency_key) .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("post") + .method(Method::Post) .path(path) .header("X-Whatever", b"aoitbeh") // missing Idempotency-Key .body(body) + .build_verifier() .verify(&tl_signature) .expect_err("verify should fail"); } @@ -313,19 +338,21 @@ fn full_request_signature_required_header_missing_from_signature() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .header("Idempotency-Key", idempotency_key) .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("post") + .method(Method::Post) .path(path) .require_header("X-Required") // missing from signature .header("Idempotency-Key", idempotency_key) .body(body) + .build_verifier() .verify(&tl_signature) .expect_err("verify should fail"); } @@ -337,19 +364,21 @@ fn full_request_signature_required_header_case_insensitive() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .header("Idempotency-Key", idempotency_key) .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("post") + .method(Method::Post) .path(path) .require_header("IdEmPoTeNcY-KeY") // case insensitive so should be fine .header("Idempotency-Key", idempotency_key) .body(body) + .build_verifier() .verify(&tl_signature) .expect("verify should work"); } @@ -361,20 +390,22 @@ fn flexible_header_case_order_verify() { let path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) - .method("post") + .method(Method::Post) .path(path) .header("Idempotency-Key", idempotency_key) .header("X-Custom", b"123") .body(body) + .build_signer() .sign() .expect("sign"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .method("post") + .method(Method::Post) .path(path) .header("X-CUSTOM", b"123") // different order & case, it's ok! .header("idempotency-key", idempotency_key) // different order & case, it's ok! .body(body) + .build_verifier() .verify(&tl_signature) .expect("verify"); } @@ -385,11 +416,12 @@ fn flexible_header_case_order_verify() { fn set_jku() { let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) .jku("https://webhooks.truelayer.com/.well-known/jwks") - .method("POST") + .method(Method::Post) .path("/tl-webhook") .header("X-Tl-Webhook-Timestamp", b"2021-11-29T11:42:55Z") .header("Content-Type", b"application/json") .body(br#"{"event_type":"example","event_id":"18b2842b-a57b-4887-a0a6-d3c7c36f1020"}"#) + .build_signer() .sign() .expect("sign"); @@ -428,20 +460,22 @@ fn verify_with_jwks() { let jwks = include_bytes!("../../test-resources/jwks.json"); truelayer_signing::verify_with_jwks(jwks) - .method("POST") + .method(Method::Post) .path("/tl-webhook") .header("x-tl-webhook-timestamp", b"2021-11-29T11:42:55Z") .header("content-type", b"application/json") .body(br#"{"event_type":"example","event_id":"18b2842b-a57b-4887-a0a6-d3c7c36f1020"}"#) + .build_verifier() .verify(hook_signature) .expect("verify"); truelayer_signing::verify_with_jwks(jwks) - .method("POST") + .method(Method::Post) .path("/tl-webhook") .header("x-tl-webhook-timestamp", b"2021-12-02T14:18:00Z") // different .header("content-type", b"application/json") .body(br#"{"event_type":"example","event_id":"18b2842b-a57b-4887-a0a6-d3c7c36f1020"}"#) + .build_verifier() .verify(hook_signature) .expect_err("verify should fail as header is different"); } @@ -456,6 +490,7 @@ fn body_signature() { let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) .body(body) + .build_v1_signer() .sign_body_only() .expect("sign_body"); @@ -463,9 +498,9 @@ fn body_signature() { eprintln!("signature: {tl_signature}"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .allow_v1(true) .body(body) - .verify(&tl_signature) + .build_v1_verifier() + .verify_body_only(&tl_signature) .expect("verify"); } @@ -473,13 +508,14 @@ fn body_signature() { fn body_signature_mismatch() { let tl_signature = truelayer_signing::sign_with_pem(KID, PRIVATE_KEY) .body(br#"{"abc":123}"#) + .build_v1_signer() .sign_body_only() .expect("sign_body"); truelayer_signing::verify_with_pem(PUBLIC_KEY) - .allow_v1(true) .body(br#"{"abc":124}"#) // different - .verify(&tl_signature) + .build_v1_verifier() + .verify_body_only(&tl_signature) .expect_err("verify should fail"); } @@ -489,20 +525,8 @@ fn verify_body_static_signature() { let tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2NDktNDEzNC04NGIzLTE5MmMyYzc4ZTk5MCJ9..ASwrHoHm-1tuvTWj_YFbrMZiP22sUHEu826cJC7flb9nZLwdfP0L-RDhBA5csNLM2KtkAOD7pnJYS7tnw383gtuxAWnXI_NbJ5rZuYWVgVlqc9VCt8lkvyQZtKOiRQfpFmJWBDNULHWwFTyrX2UaOO_KWHnZ4_8jpNaNsyeQGe61gfk-"; truelayer_signing::verify_with_pem(PUBLIC_KEY) - .allow_v1(true) .body(body) - .verify(tl_signature) + .build_v1_verifier() + .verify_body_only(tl_signature) .expect("verify"); } - -#[test] -fn verify_body_static_signature_not_allowed() { - let body = br#"{"abc":123}"#; - let tl_signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2NDktNDEzNC04NGIzLTE5MmMyYzc4ZTk5MCJ9..ASwrHoHm-1tuvTWj_YFbrMZiP22sUHEu826cJC7flb9nZLwdfP0L-RDhBA5csNLM2KtkAOD7pnJYS7tnw383gtuxAWnXI_NbJ5rZuYWVgVlqc9VCt8lkvyQZtKOiRQfpFmJWBDNULHWwFTyrX2UaOO_KWHnZ4_8jpNaNsyeQGe61gfk-"; - - truelayer_signing::verify_with_pem(PUBLIC_KEY) - // v1 not allowed by default - .body(body) - .verify(tl_signature) - .expect_err("verify should not be allowed"); -}